This commit is contained in:
zwgczx committed 2026-03-06 11:36:47 +08:00
1 parent 75f293f877
commit d8f8b629d2
8 files changed
+350 -956

No files matched your search

+177 -23
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef } from 'ag-grid-community'
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import localforage from 'localforage'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import { industryTypeList } from '@/sql'
import { SwitchRoot, SwitchThumb } from 'reka-ui'
interface DetailRow {
id: string
@@ -20,12 +21,19 @@ interface DetailRow {
amount: number | null
landArea: number | null
path: string[]
hide?: boolean
}
interface XmBaseInfoState {
projectIndustry?: string
}
interface GridPersistState {
detailRows?: DetailRow[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
const props = defineProps<{
title: string
rowData: DetailRow[]
@@ -34,6 +42,7 @@ const props = defineProps<{
const BASE_INFO_KEY = 'xm-base-info-v1'
let persistTimer: ReturnType<typeof setTimeout> | null = null
const gridApi = ref<GridApi<DetailRow> | null>(null)
const activeIndustryId = ref('')
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
@@ -45,6 +54,18 @@ const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资'
})
const roughCalcEnabled = ref(false)
const visibleRowData = computed(() => props.rowData.filter(row => !row.hide))
const refreshPinnedTotalLabelCell = () => {
if (!gridApi.value) return
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
if (!pinnedTopNode) return
gridApi.value.refreshCells({
rowNodes: [pinnedTopNode],
force: true
})
}
const columnDefs: ColDef<DetailRow>[] = [
{
@@ -53,14 +74,21 @@ const columnDefs: ColDef<DetailRow>[] = [
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost),
editable: params => {
if (roughCalcEnabled.value) return Boolean(params.node?.rowPinned)
return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost)
},
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasCost
roughCalcEnabled.value && params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
roughCalcEnabled.value && params.node?.rowPinned
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
},
aggFunc: decimalAggSum,
valueParser: params => {
@@ -69,6 +97,11 @@ const columnDefs: ColDef<DetailRow>[] = [
return Number.isFinite(v) ? roundTo(v, 2) : null
},
valueFormatter: params => {
if (roughCalcEnabled.value) {
if (!params.node?.rowPinned) return ''
if (params.value == null || params.value === '') return '点击输入'
return formatThousands(params.value)
}
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return ''
}
@@ -85,7 +118,7 @@ const columnDefs: ColDef<DetailRow>[] = [
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
editable: params => !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
? 'ag-right-aligned-cell editable-cell-line'
@@ -100,6 +133,9 @@ const columnDefs: ColDef<DetailRow>[] = [
return Number.isFinite(v) ? roundTo(v, 3) : null
},
valueFormatter: params => {
if (roughCalcEnabled.value) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
return ''
}
@@ -129,18 +165,16 @@ const autoGroupColumnDef: ColDef = {
}
}
const totalAmount = computed(() => sumByNumber(props.rowData, row => row.amount))
const pinnedTopRowData = computed<DetailRow[]>(() => [
const pinnedTopRowData = ref<DetailRow[]>([
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: totalLabel.value,
majorName: '',
hasCost: false,
hasArea: false,
amount: totalAmount.value,
amount: null,
landArea: null,
path: ['TOTAL']
}
@@ -148,19 +182,74 @@ const pinnedTopRowData = computed<DetailRow[]>(() => [
const saveToIndexedDB = async () => {
try {
await localforage.setItem(props.dbKey, {
detailRows: JSON.parse(JSON.stringify(props.rowData))
})
const payload: GridPersistState = {
detailRows: props.rowData.map(row => ({
...JSON.parse(JSON.stringify(row)),
hide: Boolean(row.hide)
}))
}
payload.roughCalcEnabled = roughCalcEnabled.value
payload.totalAmount = pinnedTopRowData.value[0].amount
await localforage.setItem(props.dbKey, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const onCellValueChanged = (_event: CellValueChangedEvent) => {
const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 1000)
}, 600)
}
const setDetailRowsHidden = (hidden: boolean) => {
for (const row of props.rowData) {
row.hide = hidden
}
}
const onRoughCalcSwitch = (checked: boolean) => {
gridApi.value?.stopEditing(true)
roughCalcEnabled.value = checked
setDetailRowsHidden(checked)
if (!checked) {
syncPinnedTotalForNormalMode()
}else{
pinnedTopRowData.value[0].amount = null
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', null)
}
}
schedulePersist()
}
const onCellValueChanged = (event: CellValueChangedEvent) => {
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
if (typeof event.newValue === 'number') {
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
} else {
const parsed = Number(event.newValue)
pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
}
} else if (!roughCalcEnabled.value) {
syncPinnedTotalForNormalMode()
}
schedulePersist()
}
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
void loadIndustryFromBaseInfo()
void loadGridPersistState()
void refreshPinnedTotalLabelCell()
}
const processCellForClipboard = (params: any) => {
@@ -191,16 +280,69 @@ const loadIndustryFromBaseInfo = async () => {
}
}
onMounted(() => {
void loadIndustryFromBaseInfo()
const loadGridPersistState = async () => {
try {
const data = await localforage.getItem<GridPersistState>(props.dbKey)
roughCalcEnabled.value = Boolean(data?.roughCalcEnabled)
const detailRows = Array.isArray(data?.detailRows) ? data.detailRows : []
const detailRowById = new Map(detailRows.map(row => [row.id, row]))
for (const row of props.rowData) {
const persisted = detailRowById.get(row.id)
if (!persisted) {
row.hide = roughCalcEnabled.value
continue
}
row.amount = typeof persisted.amount === 'number' ? roundTo(persisted.amount, 2) : null
row.landArea = typeof persisted.landArea === 'number' ? roundTo(persisted.landArea, 3) : null
row.hide = typeof persisted.hide === 'boolean' ? persisted.hide : roughCalcEnabled.value
}
pinnedTopRowData.value[0].amount = typeof data?.totalAmount === 'number' ? data.totalAmount : null
const pinnedTopNode = (gridApi as any).value.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', pinnedTopRowData.value[0].amount)
}
} catch (error) {
console.error('loadGridPersistState failed:', error)
roughCalcEnabled.value = false
pinnedTopRowData.value[0].amount= null
}
}
watch(totalLabel, () => {
refreshPinnedTotalLabelCell()
})
onActivated(() => {
void loadIndustryFromBaseInfo()
})
const syncPinnedTotalForNormalMode = () => {
if (roughCalcEnabled.value) return
if (!gridApi.value) {
pinnedTopRowData.value[0].amount = sumByNumber(props.rowData, row => row.amount)
return
}
let total = 0
let hasValue = false
props.rowData.forEach(node => {
const amount = node.amount
if (typeof amount === 'number' && Number.isFinite(amount)) {
total += amount
hasValue = true
}
})
pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
if (pinnedTopNode) {
pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null)
}
}
onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer)
gridApi.value = null
void saveToIndexedDB()
})
</script>
@@ -212,18 +354,30 @@ onBeforeUnmount(() => {
<h3 class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
{{ props.title }}
</h3>
<div class="text-xs text-muted-foreground"></div>
<div class="flex items-center gap-2">
<span class=" text-xs text-muted-foreground">粗略计算</span>
<SwitchRoot
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
:modelValue="roughCalcEnabled"
@update:modelValue="onRoughCalcSwitch"
>
<SwitchThumb
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4"
/>
</SwitchRoot>
</div>
</div>
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<AgGridVue
:style="{ height: '100%' }"
:rowData="props.rowData"
:rowData="visibleRowData"
:pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions"
:theme="myTheme"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
+166 -8
View File
@@ -1,9 +1,32 @@
<script setup lang="ts">
import { parseDate } from '@internationalized/date'
import { onMounted, ref, watch } from 'vue'
import localforage from 'localforage'
import { industryTypeList } from '@/sql'
import { CircleHelp } from 'lucide-vue-next'
import { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import {
DatePickerAnchor,
DatePickerArrow,
DatePickerCalendar,
DatePickerCell,
DatePickerCellTrigger,
DatePickerClose,
DatePickerContent,
DatePickerField,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridHead,
DatePickerGridRow,
DatePickerHeadCell,
DatePickerHeader,
DatePickerHeading,
DatePickerInput,
DatePickerNext,
DatePickerPrev,
DatePickerRoot,
DatePickerTrigger
} from 'reka-ui'
interface XmInfoState {
projectIndustry?: string
@@ -31,6 +54,37 @@ const preparedBy = ref('')
const reviewedBy = ref('')
const preparedCompany = ref('')
const preparedDate = ref('')
const preparedDatePickerValue = ref<any>(undefined)
const normalizeDateString = (value: unknown): string => {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
try {
const parsed = parseDate(trimmed)
return parsed.toString() === trimmed ? trimmed : ''
} catch {
return ''
}
}
const syncPreparedDatePickerFromString = () => {
if (!preparedDate.value) {
preparedDatePickerValue.value = undefined
return
}
try {
preparedDatePickerValue.value = parseDate(preparedDate.value)
} catch {
preparedDatePickerValue.value = undefined
preparedDate.value = ''
}
}
const handlePreparedDateSelect = (date: any) => {
preparedDatePickerValue.value = date
preparedDate.value = date?.toString() ?? ''
}
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({
id: item.id,
@@ -69,7 +123,8 @@ const loadFromIndexedDB = async () => {
preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : ''
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
preparedDate.value = typeof data.preparedDate === 'string' ? data.preparedDate : ''
preparedDate.value = normalizeDateString(data.preparedDate)
syncPreparedDatePickerFromString()
return
}
@@ -80,6 +135,7 @@ const loadFromIndexedDB = async () => {
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = ''
syncPreparedDatePickerFromString()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
@@ -88,6 +144,7 @@ const loadFromIndexedDB = async () => {
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = ''
syncPreparedDatePickerFromString()
}
}
@@ -126,6 +183,7 @@ const createProject = async () => {
reviewedBy.value = ''
preparedCompany.value = ''
preparedDate.value = ''
syncPreparedDatePickerFromString()
isProjectInitialized.value = true
showCreateDialog.value = false
await saveToIndexedDB()
@@ -238,12 +296,112 @@ onMounted(async () => {
<div>
<label class="block text-sm font-medium text-foreground">编制日期</label>
<input
v-model="preparedDate"
type="text"
placeholder="请输入编制日期"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
<DatePickerRoot
locale="en-CA"
:model-value="preparedDatePickerValue"
@update:model-value="handlePreparedDateSelect"
>
<DatePickerAnchor class="mt-2 block w-full">
<DatePickerField
v-slot="{ segments }"
class="flex h-10 w-full items-center justify-between rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-ring"
>
<div class="flex items-center">
<template
v-for="(segment, index) in segments"
:key="`${segment.part}-${index}`"
>
<DatePickerInput
:part="segment.part"
:class="segment.part === 'literal' ? 'text-muted-foreground/70' : 'text-foreground'"
>
{{ segment.part === 'literal' ? '-' : segment.value }}
</DatePickerInput>
</template>
</div>
<DatePickerTrigger as-child>
<button type="button" class="inline-flex h-6 w-6 items-center justify-center text-muted-foreground">
<CalendarIcon class="h-4 w-4" />
</button>
</DatePickerTrigger>
</DatePickerField>
</DatePickerAnchor>
<DatePickerContent class="z-50 w-[22rem] rounded-lg border bg-card p-4 shadow-lg">
<DatePickerArrow class="fill-border" />
<DatePickerCalendar v-slot="{ weekDays, grid }" locale="zh-CN">
<DatePickerHeader class="mb-2 flex items-center justify-between">
<DatePickerPrev as-child>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
>
‹
</button>
</DatePickerPrev>
<DatePickerHeading class="text-base font-medium text-foreground" />
<DatePickerNext as-child>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
>
›
</button>
</DatePickerNext>
</DatePickerHeader>
<div class="space-y-2">
<DatePickerGrid
v-for="month in grid"
:key="month.value.toString()"
class="w-full border-collapse select-none"
>
<DatePickerGridHead>
<DatePickerGridRow class="grid grid-cols-7">
<DatePickerHeadCell
v-for="day in weekDays"
:key="day"
class="flex h-9 items-center justify-center text-sm text-muted-foreground"
>
{{ day }}
</DatePickerHeadCell>
</DatePickerGridRow>
</DatePickerGridHead>
<DatePickerGridBody>
<DatePickerGridRow
v-for="(weekDates, index) in month.rows"
:key="`${month.value.toString()}-${index}`"
class="grid grid-cols-7"
>
<DatePickerCell
v-for="dateValue in weekDates"
:key="dateValue.toString()"
:date="dateValue"
class="h-10 w-full p-0.5"
>
<DatePickerCellTrigger
:day="dateValue"
:month="month.value"
class="h-full w-full rounded-md border border-transparent bg-transparent text-base outline-none transition hover:bg-muted data-[outside-view]:text-muted-foreground/40 data-[selected]:border-primary data-[selected]:bg-transparent data-[selected]:text-foreground data-[disabled]:opacity-40 data-[unavailable]:text-muted-foreground/40"
>
{{ dateValue.day }}
</DatePickerCellTrigger>
</DatePickerCell>
</DatePickerGridRow>
</DatePickerGridBody>
</DatePickerGrid>
</div>
</DatePickerCalendar>
<div class="mt-2 flex justify-end">
<DatePickerClose as-child>
<button
type="button"
class="h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted"
>
关闭
</button>
</DatePickerClose>
</div>
</DatePickerContent>
</DatePickerRoot>
</div>
</div>
</div>