fix
This commit is contained in:
parent
75f293f877
commit
d8f8b629d2
5
bun.lock
5
bun.lock
@ -7,6 +7,7 @@
|
||||
"dependencies": {
|
||||
"@ag-grid-community/locale": "^35.1.0",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@ag-grid-community/locale": "^35.1.0",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"ag-grid-community": "^35.1.0",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
RowAutoHeightModule,
|
||||
TextEditorModule,
|
||||
TooltipModule,
|
||||
UndoRedoEditModule,
|
||||
UndoRedoEditModule,RenderApiModule
|
||||
|
||||
} from 'ag-grid-community'
|
||||
import {
|
||||
@ -40,7 +40,7 @@ const AG_GRID_MODULES = [
|
||||
LargeTextEditorModule,
|
||||
UndoRedoEditModule,
|
||||
CellStyleModule,
|
||||
PinnedRowModule,
|
||||
PinnedRowModule,RenderApiModule ,
|
||||
TooltipModule,
|
||||
TreeDataModule,
|
||||
AggregationModule,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user