This commit is contained in:
wintsa 2026-03-06 11:36:47 +08:00
parent 75f293f877
commit d8f8b629d2
8 changed files with 350 additions and 956 deletions

View File

@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@ag-grid-community/locale": "^35.1.0", "@ag-grid-community/locale": "^35.1.0",
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@internationalized/date": "^3.12.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"ag-grid-community": "^35.1.0", "ag-grid-community": "^35.1.0",
@ -72,7 +73,7 @@
"@iconify/vue": ["@iconify/vue@5.0.0", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3" } }, "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg=="], "@iconify/vue": ["@iconify/vue@5.0.0", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3" } }, "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg=="],
"@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="], "@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
@ -542,6 +543,8 @@
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"reka-ui/@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@ag-grid-community/locale": "^35.1.0", "@ag-grid-community/locale": "^35.1.0",
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@internationalized/date": "^3.12.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"ag-grid-community": "^35.1.0", "ag-grid-community": "^35.1.0",

View File

@ -1,13 +1,14 @@
<script setup lang="ts"> <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 { 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 { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import localforage from 'localforage' import localforage from 'localforage'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat' import { formatThousands } from '@/lib/numberFormat'
import { industryTypeList } from '@/sql' import { industryTypeList } from '@/sql'
import { SwitchRoot, SwitchThumb } from 'reka-ui'
interface DetailRow { interface DetailRow {
id: string id: string
@ -20,12 +21,19 @@ interface DetailRow {
amount: number | null amount: number | null
landArea: number | null landArea: number | null
path: string[] path: string[]
hide?: boolean
} }
interface XmBaseInfoState { interface XmBaseInfoState {
projectIndustry?: string projectIndustry?: string
} }
interface GridPersistState {
detailRows?: DetailRow[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
const props = defineProps<{ const props = defineProps<{
title: string title: string
rowData: DetailRow[] rowData: DetailRow[]
@ -34,6 +42,7 @@ const props = defineProps<{
const BASE_INFO_KEY = 'xm-base-info-v1' const BASE_INFO_KEY = 'xm-base-info-v1'
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
const gridApi = ref<GridApi<DetailRow> | null>(null)
const activeIndustryId = ref('') const activeIndustryId = ref('')
const industryNameMap = new Map( const industryNameMap = new Map(
industryTypeList.flatMap(item => [ industryTypeList.flatMap(item => [
@ -45,6 +54,18 @@ const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || '' const industryName = industryNameMap.get(activeIndustryId.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资' 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>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ {
@ -53,14 +74,21 @@ const columnDefs: ColDef<DetailRow>[] = [
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 100, minWidth: 100,
flex: 1, 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 => 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 editable-cell-line'
: 'ag-right-aligned-cell', : 'ag-right-aligned-cell',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => '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, aggFunc: decimalAggSum,
valueParser: params => { valueParser: params => {
@ -69,6 +97,11 @@ const columnDefs: ColDef<DetailRow>[] = [
return Number.isFinite(v) ? roundTo(v, 2) : null return Number.isFinite(v) ? roundTo(v, 2) : null
}, },
valueFormatter: params => { 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) { if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return '' return ''
} }
@ -85,7 +118,7 @@ const columnDefs: ColDef<DetailRow>[] = [
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 100, minWidth: 100,
flex: 1, 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 => cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea !params.node?.group && !params.node?.rowPinned && params.data?.hasArea
? 'ag-right-aligned-cell editable-cell-line' ? 'ag-right-aligned-cell editable-cell-line'
@ -100,6 +133,9 @@ const columnDefs: ColDef<DetailRow>[] = [
return Number.isFinite(v) ? roundTo(v, 3) : null return Number.isFinite(v) ? roundTo(v, 3) : null
}, },
valueFormatter: params => { valueFormatter: params => {
if (roughCalcEnabled.value) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) { if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
return '' return ''
} }
@ -129,18 +165,16 @@ const autoGroupColumnDef: ColDef = {
} }
} }
const totalAmount = computed(() => sumByNumber(props.rowData, row => row.amount)) const pinnedTopRowData = ref<DetailRow[]>([
const pinnedTopRowData = computed<DetailRow[]>(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
groupCode: '', groupCode: '',
groupName: '', groupName: '',
majorCode: '', majorCode: '',
majorName: totalLabel.value, majorName: '',
hasCost: false, hasCost: false,
hasArea: false, hasArea: false,
amount: totalAmount.value, amount: null,
landArea: null, landArea: null,
path: ['TOTAL'] path: ['TOTAL']
} }
@ -148,19 +182,74 @@ const pinnedTopRowData = computed<DetailRow[]>(() => [
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
try { try {
await localforage.setItem(props.dbKey, { const payload: GridPersistState = {
detailRows: JSON.parse(JSON.stringify(props.rowData)) 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) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
} }
} }
const onCellValueChanged = (_event: CellValueChangedEvent) => { const schedulePersist = () => {
if (persistTimer) clearTimeout(persistTimer) if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => { persistTimer = setTimeout(() => {
void saveToIndexedDB() 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) => { const processCellForClipboard = (params: any) => {
@ -191,16 +280,69 @@ const loadIndustryFromBaseInfo = async () => {
} }
} }
onMounted(() => { const loadGridPersistState = async () => {
void loadIndustryFromBaseInfo() 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(() => { const syncPinnedTotalForNormalMode = () => {
void loadIndustryFromBaseInfo() 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(() => { onBeforeUnmount(() => {
if (persistTimer) clearTimeout(persistTimer) if (persistTimer) clearTimeout(persistTimer)
gridApi.value = null
void saveToIndexedDB() void saveToIndexedDB()
}) })
</script> </script>
@ -212,18 +354,30 @@ onBeforeUnmount(() => {
<h3 class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"> <h3 class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
{{ props.title }} {{ props.title }}
</h3> </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>
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full"> <div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
<AgGridVue <AgGridVue
:style="{ height: '100%' }" :style="{ height: '100%' }"
:rowData="props.rowData" :rowData="visibleRowData"
:pinnedTopRowData="pinnedTopRowData" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :columnDefs="columnDefs"
:autoGroupColumnDef="autoGroupColumnDef" :autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions" :gridOptions="gridOptions"
:theme="myTheme" :theme="myTheme"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged" @cell-value-changed="onCellValueChanged"
:suppressColumnVirtualisation="true" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :suppressRowVirtualisation="true"

View File

@ -1,9 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseDate } from '@internationalized/date'
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import localforage from 'localforage' import localforage from 'localforage'
import { industryTypeList } from '@/sql' 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 { 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 { interface XmInfoState {
projectIndustry?: string projectIndustry?: string
@ -31,6 +54,37 @@ const preparedBy = ref('')
const reviewedBy = ref('') const reviewedBy = ref('')
const preparedCompany = ref('') const preparedCompany = ref('')
const preparedDate = 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 => ({ const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({
id: item.id, id: item.id,
@ -69,7 +123,8 @@ const loadFromIndexedDB = async () => {
preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : '' preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : ''
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : '' reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : '' preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
preparedDate.value = typeof data.preparedDate === 'string' ? data.preparedDate : '' preparedDate.value = normalizeDateString(data.preparedDate)
syncPreparedDatePickerFromString()
return return
} }
@ -80,6 +135,7 @@ const loadFromIndexedDB = async () => {
reviewedBy.value = '' reviewedBy.value = ''
preparedCompany.value = '' preparedCompany.value = ''
preparedDate.value = '' preparedDate.value = ''
syncPreparedDatePickerFromString()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
@ -88,6 +144,7 @@ const loadFromIndexedDB = async () => {
reviewedBy.value = '' reviewedBy.value = ''
preparedCompany.value = '' preparedCompany.value = ''
preparedDate.value = '' preparedDate.value = ''
syncPreparedDatePickerFromString()
} }
} }
@ -126,6 +183,7 @@ const createProject = async () => {
reviewedBy.value = '' reviewedBy.value = ''
preparedCompany.value = '' preparedCompany.value = ''
preparedDate.value = '' preparedDate.value = ''
syncPreparedDatePickerFromString()
isProjectInitialized.value = true isProjectInitialized.value = true
showCreateDialog.value = false showCreateDialog.value = false
await saveToIndexedDB() await saveToIndexedDB()
@ -238,12 +296,112 @@ onMounted(async () => {
<div> <div>
<label class="block text-sm font-medium text-foreground">编制日期</label> <label class="block text-sm font-medium text-foreground">编制日期</label>
<input <DatePickerRoot
v-model="preparedDate" locale="en-CA"
type="text" :model-value="preparedDatePickerValue"
placeholder="请输入编制日期" @update:model-value="handlePreparedDateSelect"
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" >
/> <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> </div>
</div> </div>

View File

@ -10,7 +10,7 @@ import {
RowAutoHeightModule, RowAutoHeightModule,
TextEditorModule, TextEditorModule,
TooltipModule, TooltipModule,
UndoRedoEditModule, UndoRedoEditModule,RenderApiModule
} from 'ag-grid-community' } from 'ag-grid-community'
import { import {
@ -40,7 +40,7 @@ const AG_GRID_MODULES = [
LargeTextEditorModule, LargeTextEditorModule,
UndoRedoEditModule, UndoRedoEditModule,
CellStyleModule, CellStyleModule,
PinnedRowModule, PinnedRowModule,RenderApiModule ,
TooltipModule, TooltipModule,
TreeDataModule, TreeDataModule,
AggregationModule, AggregationModule,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,351 +0,0 @@
1 <script setup lang="ts">
2 import { computed, onBeforeUnmount, ref, watch, type Component ,onMounted} from 'vue'
3 import { ScrollArea } from '@/components/ui/scroll-area'
4 import { Button } from '@/components/ui/button'
5 import {
6 DialogClose,
7 DialogContent,
8 DialogOverlay,
9 DialogPortal,
10 DialogRoot,
11 DialogTitle,
12 DialogTrigger,DialogDescription
13 } from 'reka-ui'
14 import { Icon } from '@iconify/vue'
15 import { useWindowSize } from '@vueuse/core'
16 import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
17 interface TypeLineCategory {
18 key: string
19 label: string
20 component: Component
21 }
22
23 const props = withDefaults(
24 defineProps<{
25 scene?: string
26 title?: string
27 subtitle?: string
28 copyText?: string
29 categories: TypeLineCategory[]
30 storageKey?: string
31 defaultCategory?: string
32 }>(),
33 {
34 scene: 'default',
35 title: '配置',
36 subtitle: '',
37 copyText: '',
38 storageKey: '',
39 defaultCategory: ''
40 }
41 )
42
43 const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
44
45 const resolveInitialCategory = () => {
46 const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
47 const savedKey = sessionStorage.getItem(cacheKey.value)
48 const validSavedKey = props.categories.some(item => item.key === savedKey)
49 return validSavedKey ? (savedKey as string) : defaultKey
50 }
51
52 const activeCategory = ref(resolveInitialCategory())
53
54 watch(
55 () => [props.categories, props.defaultCategory, cacheKey.value],
56 () => {
57 activeCategory.value = resolveInitialCategory()
58 },
59 { deep: true }
60 )
61
62 const switchCategory = (cat: string) => {
63 activeCategory.value = cat
64 sessionStorage.setItem(cacheKey.value, cat)
65 }
66
67 const activeComponent = computed(() => {
68 const selected = props.categories.find(item => item.key === activeCategory.value)
69 return selected?.component || props.categories[0]?.component || null
70 })
71
72 const copyBtnText = ref('复制')
73 const sheetOpen = ref(false)
74 let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
75
76 const handleCopySubtitle = async () => {
77 const text = (props.copyText || '').trim()
78 if (!text) return
79
80 try {
81 await navigator.clipboard.writeText(text)
82 copyBtnText.value = '已复制'
83 } catch (error) {
84 console.error('copy failed:', error)
85 copyBtnText.value = '复制失败'
86 }
87
88 if (copyBtnTimer) clearTimeout(copyBtnTimer)
89 copyBtnTimer = setTimeout(() => {
90 copyBtnText.value = '复制'
91 }, 1200)
92 }
93
94 onBeforeUnmount(() => {
95 if (copyBtnTimer) clearTimeout(copyBtnTimer)
96 if (!root) return
97 root.style.scale = ''
98 root.style.translate = ''
99 root.style.borderRadius = ''
100 })
101
102 //
103
104
105 const inertiaTransition = {
106 type: 'inertia' as const,
107 bounceStiffness: 300,
108 bounceDamping: 40,
109 timeConstant: 300,
110 }
111
112 const staticTransition = {
113 duration: 0.5,
114 ease: [0.32, 0.72, 0, 1] as const,
115 }
116
117 const SHEET_TOP_RATIO = 0.1
118 const SHEET_RADIUS = 12
119 const OFFICIAL_SITE_URL = '/'
120
121 let root: HTMLElement | null = null
122
123 onMounted(() => {
124 root = document.body.firstElementChild as HTMLElement | null
125 })
126
127 const { height, width } = useWindowSize()
128
129 const sheetTop = computed(() => Math.round(height.value * SHEET_TOP_RATIO))
130 const h = computed(() => Math.max(0, height.value - sheetTop.value))
131 const y = useMotionValue(h.value)
132
133 watch(
134 () => h.value,
135 (nextHeight) => {
136 if (!sheetOpen.value) y.jump(nextHeight)
137 }
138 )
139
140 watch(
141 () => sheetOpen.value,
142 (isOpen) => {
143 if (!isOpen) {
144 y.jump(h.value)
145 return
146 }
147 y.jump(h.value)
148 animate(y, 0, staticTransition)
149 }
150 )
151
152 // Scale the body down and adjust the border radius when the sheet is open.
153 const bodyScale = useTransform(
154 y,
155 [0, h.value],
156 [(width.value - sheetTop.value) / width.value, 1],
157 )
158 const bodyTranslate = useTransform(y, [0, h.value], [sheetTop.value - SHEET_RADIUS, 0])
159 const bodyBorderRadius = useTransform(y, [0, h.value], [SHEET_RADIUS, 0])
160
161 useMotionValueEvent(bodyScale, 'change', (v) => {
162 if (!root) return
163 root.style.scale = `${v}`
164 })
165 useMotionValueEvent(
166 bodyTranslate,
167 'change',
168 (v) => {
169 if (!root) return
170 root.style.translate = `0 ${v}px`
171 },
172 )
173 useMotionValueEvent(
174 bodyBorderRadius,
175 'change',
176 (v) => {
177 if (!root) return
178 root.style.borderRadius = `${v}px`
179 },
180 )
181 </script>
182
183 <template>
184 <div class="flex h-full w-full bg-background">
185 <div class="w-12/100 border-r p-6 flex flex-col gap-8 relative">
186 <div v-if="props.title || props.subtitle" class="space-y-1">
187 <div v-if="props.title" class="font-bold text-base leading-6 text-primary break-words">
188 {{ props.title }}
189 </div>
190 <div
191 v-if="props.subtitle"
192 class="flex flex-wrap items-center gap-2 text-xs leading-5 text-muted-foreground"
193 >
194 <span class="break-all">{{ props.subtitle }}</span>
195 <Button
196 v-if="props.copyText"
197 type="button"
198 variant="outline"
199 size="sm"
200 class="h-6 rounded-md px-2 text-[11px]"
201 @click.stop="handleCopySubtitle"
202 >
203 {{ copyBtnText }}
204 </Button>
205 </div>
206 </div>
207
208 <div class="flex flex-col gap-10 relative">
209 <div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
210
211 <div
212 v-for="item in props.categories"
213 :key="item.key"
214 class="relative flex items-center gap-4 cursor-pointer group"
215 @click="switchCategory(item.key)"
216 >
217 <div
218 :class="[
219 'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
220 activeCategory === item.key
221 ? 'bg-primary border-primary scale-110'
222 : 'bg-background border-muted-foreground'
223 ]"
224 >
225 <div v-if="activeCategory === item.key" class="w-2 h-2 bg-background rounded-full"></div>
226 </div>
227 <span
228 :class="[
229 'text-sm transition-colors',
230 activeCategory === item.key
231 ? 'font-bold text-primary'
232 : 'text-muted-foreground group-hover:text-foreground'
233 ]"
234 >
235 {{ item.label }}
236 </span>
237 </div>
238 </div>
239
240 <DialogRoot v-model:open="sheetOpen">
241 <DialogTrigger as-child>
242 <button
243 type="button"
244 class="absolute left-4 right-4 bottom-4 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[12px] leading-5 text-foreground/85 shadow-sm hover:bg-muted/55 hover:text-foreground transition-colors"
245 >
246 <img src="/favicon.ico" alt="众为咨询" class="h-5 w-5 shrink-0 rounded-sm" />
247 <span>本网站由众为工程咨询有限公司提供免费技术支持</span>
248 </button>
249 </DialogTrigger>
250 <DialogPortal>
251 <AnimatePresence
252 multiple
253 as="div"
254 >
255 <DialogOverlay as-child>
256 <Motion
257 class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]"
258 :initial="{ opacity: 0 }"
259 :animate="{ opacity: 1 }"
260 :exit="{ opacity: 0 }"
261 :transition="staticTransition"
262 />
263 </DialogOverlay>
264
265 <DialogContent as-child>
266 <Motion
267 class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-border/60 bg-card/95 shadow-2xl backdrop-blur-xl will-change-transform"
268 :style="{
269 y,
270 top: `${sheetTop}px`,
271 }"
272 drag="y"
273 :drag-constraints="{ top: 0 }"
274 @drag-end="(e, { offset, velocity }) => {
275 if (offset.y > h * 0.35 || velocity.y > 10) {
276 sheetOpen = false;
277 }
278 else {
279 animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
280 }
281 }"
282 >
283 <div class="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/35" />
284 <div class="mx-auto flex h-full w-full max-w-2xl flex-col px-4 pb-5 pt-3">
285 <div class="mb-3 flex items-center justify-between gap-3">
286 <DialogTitle class="m-0">
287 <div class="flex items-center gap-3">
288 <img src="/favicon.ico" alt="众为咨询" class="h-7 w-7 shrink-0 rounded-sm" />
289 <span class="text-2xl font-semibold leading-none">关于我们</span>
290 </div>
291 </DialogTitle>
292 <div class="flex items-center gap-2">
293 <a
294 :href="OFFICIAL_SITE_URL"
295 target="_blank"
296 rel="noopener noreferrer"
297 class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
298 aria-label="跳转到官网首页"
299 title="官网首页"
300 >
301 <Icon icon="lucide:arrow-up-right" class="h-4 w-4" />
302 </a>
303 <DialogClose class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
304 <Icon icon="lucide:x" class="h-4 w-4" />
305 </DialogClose>
306 </div>
307 </div>
308
309 <DialogDescription class="mb-4 text-base text-muted-foreground">
310 <p class="font-medium text-foreground">众为工程咨询有限公司</p>
311 </DialogDescription>
312
313 <div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
314 <p>
315 众为工程咨询有限公司长期专注于工程咨询与数字化服务,致力于为客户提供高效、可靠、可持续的解决方案。
316 </p>
317 <p>
318 我们围绕咨询管理、数据治理、系统建设与运维支持,持续提升项目交付质量,帮助客户降低沟通成本、提升协同效率。
319 </p>
320 <p>
321 本网站由众为工程咨询有限公司提供免费技术支持,如需商务合作或技术咨询,请与我们联系。
322 </p>
323 </div>
324 </div>
325 </Motion>
326 </DialogContent>
327 </AnimatePresence>
328 </DialogPortal>
329 </DialogRoot>
330
331 </div>
332
333 <div class="w-88/100 min-h-0 h-full flex flex-col">
334 <ScrollArea class="h-full w-full min-h-0 rightMain">
335 <div class="p-3 h-full min-h-0 flex flex-col">
336 <keep-alive>
337 <component :is="activeComponent" />
338 </keep-alive>
339 </div>
340 </ScrollArea>
341 </div>
342 </div>
343 </template>
344 <style scoped>
345 /* 核心修改:添加 :deep() 穿透 scoped 作用域 */
346 :deep(.rightMain > div > div) {
347 height: 100%;
348 min-height: 0;
349 box-sizing: border-box;
350 }
351 </style>