fix
This commit is contained in:
parent
9849801e46
commit
e97707ac59
@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { Edit3, GripVertical, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { ArrowUp, Edit3, GripVertical, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import {
|
||||
ToastAction,
|
||||
ToastDescription,
|
||||
@ -51,16 +51,19 @@ let baseOffsetX = 0
|
||||
let baseOffsetY = 0
|
||||
const contractListScrollWrapRef = ref<HTMLElement | null>(null)
|
||||
const contractListViewportRef = ref<HTMLElement | null>(null)
|
||||
const showScrollTopFab = ref(false)
|
||||
const isDraggingContracts = ref(false)
|
||||
const cardMotionState = ref<'enter' | 'ready'>('ready')
|
||||
let contractAutoScrollRaf = 0
|
||||
let dragPointerClientY: number | null = null
|
||||
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let contractListScrollBoundEl: HTMLElement | null = null
|
||||
|
||||
const CARD_ENTER_STEP_MS = 58
|
||||
const CARD_ENTER_DURATION_MS = 560
|
||||
const CARD_ENTER_MAX_INDEX = 24
|
||||
const CARD_ENTER_TOTAL_MS = CARD_ENTER_DURATION_MS + CARD_ENTER_STEP_MS * CARD_ENTER_MAX_INDEX + 80
|
||||
const SCROLL_TOP_FAB_THRESHOLD = 220
|
||||
|
||||
const buildDefaultContracts = (): ContractItem[] => [
|
||||
|
||||
@ -118,6 +121,51 @@ const scrollContractsToBottom = (behavior: ScrollBehavior = 'smooth') => {
|
||||
})
|
||||
}
|
||||
|
||||
const scrollContractsToTop = (behavior: ScrollBehavior = 'smooth') => {
|
||||
const viewport = getContractListViewport()
|
||||
if (!viewport) return
|
||||
viewport.scrollTo({
|
||||
top: 0,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const updateScrollTopFabVisible = () => {
|
||||
const viewport = contractListViewportRef.value
|
||||
showScrollTopFab.value = Boolean(viewport && viewport.scrollTop > SCROLL_TOP_FAB_THRESHOLD)
|
||||
}
|
||||
|
||||
const handleContractListScroll = () => {
|
||||
updateScrollTopFabVisible()
|
||||
}
|
||||
|
||||
const bindContractListScroll = () => {
|
||||
const viewport = getContractListViewport()
|
||||
if (contractListScrollBoundEl === viewport) {
|
||||
updateScrollTopFabVisible()
|
||||
return
|
||||
}
|
||||
if (contractListScrollBoundEl) {
|
||||
contractListScrollBoundEl.removeEventListener('scroll', handleContractListScroll)
|
||||
contractListScrollBoundEl = null
|
||||
}
|
||||
if (!viewport) {
|
||||
showScrollTopFab.value = false
|
||||
return
|
||||
}
|
||||
contractListScrollBoundEl = viewport
|
||||
contractListScrollBoundEl.addEventListener('scroll', handleContractListScroll, { passive: true })
|
||||
updateScrollTopFabVisible()
|
||||
}
|
||||
|
||||
const unbindContractListScroll = () => {
|
||||
if (contractListScrollBoundEl) {
|
||||
contractListScrollBoundEl.removeEventListener('scroll', handleContractListScroll)
|
||||
contractListScrollBoundEl = null
|
||||
}
|
||||
showScrollTopFab.value = false
|
||||
}
|
||||
|
||||
const triggerCardEnterAnimation = () => {
|
||||
if (cardEnterTimer) {
|
||||
clearTimeout(cardEnterTimer)
|
||||
@ -379,19 +427,20 @@ onMounted(async () => {
|
||||
await loadContracts()
|
||||
triggerCardEnterAnimation()
|
||||
await nextTick()
|
||||
getContractListViewport()
|
||||
bindContractListScroll()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
triggerCardEnterAnimation()
|
||||
void nextTick(() => {
|
||||
getContractListViewport()
|
||||
bindContractListScroll()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDrag()
|
||||
stopContractAutoScroll()
|
||||
unbindContractListScroll()
|
||||
if (cardEnterTimer) clearTimeout(cardEnterTimer)
|
||||
void saveContracts()
|
||||
})
|
||||
@ -410,13 +459,29 @@ onBeforeUnmount(() => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<input
|
||||
v-model="contractSearchKeyword"
|
||||
type="text"
|
||||
placeholder="搜索合同段名称或ID"
|
||||
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring md:max-w-md"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-start">
|
||||
<div class="w-full md:max-w-md">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="contractSearchKeyword"
|
||||
type="text"
|
||||
placeholder="搜索合同段名称或ID"
|
||||
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
v-if="contractSearchKeyword"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-10 shrink-0 px-3"
|
||||
@click="contractSearchKeyword = ''"
|
||||
>
|
||||
清空筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="isSearchingContracts" class="mt-1 text-xs text-muted-foreground">
|
||||
搜索中({{ filteredContracts.length }} / {{ contracts.length }}),已关闭拖拽排序
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 md:ml-auto">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 text-xs text-muted-foreground select-none">
|
||||
<span>{{ isListLayout ? '列表布局' : '网格布局' }}</span>
|
||||
@ -434,17 +499,6 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
<Button
|
||||
v-if="contractSearchKeyword"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="contractSearchKeyword = ''"
|
||||
>
|
||||
清空筛选
|
||||
</Button>
|
||||
<div v-if="isSearchingContracts" class="text-xs text-muted-foreground">
|
||||
搜索中({{ filteredContracts.length }} / {{ contracts.length }}),已关闭拖拽排序
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -664,6 +718,19 @@ onBeforeUnmount(() => {
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="回到顶部"
|
||||
aria-label="回到顶部"
|
||||
:class="[
|
||||
'fixed bottom-8 right-8 z-40 inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-black/15 bg-white text-black shadow-[0_10px_24px_rgba(0,0,0,0.16)] transition-all duration-300 hover:scale-105 hover:border-black/30 hover:bg-black hover:text-white',
|
||||
showScrollTopFab ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-3 opacity-0'
|
||||
]"
|
||||
@click="scrollContractsToTop()"
|
||||
>
|
||||
<ArrowUp class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
|
||||
@ -5,7 +5,7 @@ import type { ColDef } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { addNumbers, decimalAggSum, sumByNumber } from '@/lib/decimal'
|
||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
@ -234,18 +234,18 @@ const formatMajorFactor = (params: any) => {
|
||||
|
||||
const formatReadonlyNumber = (params: any) => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
return roundTo(params.value, 2).toFixed(2)
|
||||
}
|
||||
|
||||
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
|
||||
const result = getBasicFeeFromScale(row?.amount, 'cost')
|
||||
return result ? addNumbers(result.basic, result.optional) : null
|
||||
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
|
||||
}
|
||||
|
||||
const getBudgetFee = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor'>) => {
|
||||
const benchmarkBudget = getBenchmarkBudgetByAmount(row)
|
||||
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null
|
||||
return benchmarkBudget * row.majorFactor * row.consultCategoryFactor
|
||||
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
|
||||
@ -5,7 +5,7 @@ import type { ColDef } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { addNumbers, decimalAggSum, sumByNumber } from '@/lib/decimal'
|
||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
@ -237,18 +237,18 @@ const formatMajorFactor = (params: any) => {
|
||||
|
||||
const formatReadonlyNumber = (params: any) => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return Number(params.value).toFixed(2)
|
||||
return roundTo(params.value, 2).toFixed(2)
|
||||
}
|
||||
|
||||
const getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) => {
|
||||
const result = getBasicFeeFromScale(row?.landArea, 'area')
|
||||
return result ? addNumbers(result.basic, result.optional) : null
|
||||
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
|
||||
}
|
||||
|
||||
const getBudgetFee = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor'>) => {
|
||||
const benchmarkBudget = getBenchmarkBudgetByLandArea(row)
|
||||
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null
|
||||
return benchmarkBudget * row.majorFactor * row.consultCategoryFactor
|
||||
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
|
||||
}
|
||||
|
||||
const formatEditableFlexibleNumber = (params: any) => {
|
||||
|
||||
@ -5,7 +5,7 @@ import type { ColDef } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { serviceList, taskList } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum } from '@/lib/decimal'
|
||||
import { decimalAggSum, roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
@ -91,14 +91,20 @@ const formatTaskReferenceUnitPrice = (task: taskLite) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
const getSourceTaskIds = () => {
|
||||
const currentServiceId = Number(props.serviceId)
|
||||
const sourceTaskIds = Object.entries(taskList as Record<string, taskLite>)
|
||||
return Object.entries(taskList as Record<string, taskLite>)
|
||||
.filter(([, task]) => Number(task.serviceID) === currentServiceId)
|
||||
.map(([key]) => Number(key))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
const isWorkloadMethodApplicable = computed(() => getSourceTaskIds().length > 0)
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
const sourceTaskIds = getSourceTaskIds()
|
||||
|
||||
for (const [order, taskId] of sourceTaskIds.entries()) {
|
||||
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
|
||||
@ -122,25 +128,6 @@ const buildDefaultRows = (): DetailRow[] => {
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
const emptyRowId = `task-none-${String(props.serviceId)}`
|
||||
rows.push({
|
||||
id: emptyRowId,
|
||||
taskCode: '无',
|
||||
taskName: '无',
|
||||
unit: '',
|
||||
conversion: null,
|
||||
workload: null,
|
||||
budgetBase: '无',
|
||||
budgetReferenceUnitPrice: '无',
|
||||
budgetAdoptedUnitPrice: null,
|
||||
consultCategoryFactor: null,
|
||||
serviceFee: null,
|
||||
remark: '',
|
||||
path: [emptyRowId]
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@ -194,7 +181,7 @@ const calcServiceFee = (row: DetailRow | undefined) => {
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return price * conversion * workload * factor
|
||||
return roundTo(toDecimal(price).mul(conversion).mul(workload).mul(factor), 2)
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
@ -206,6 +193,12 @@ const formatEditableNumber = (params: any) => {
|
||||
return Number(params.value).toFixed(2)
|
||||
}
|
||||
|
||||
const formatReadonlyNumber = (params: any) => {
|
||||
if (isNoTaskRow(params.data)) return '无'
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return roundTo(params.value, 2).toFixed(2)
|
||||
}
|
||||
|
||||
const spanRowsByTaskName = (params: any) => {
|
||||
const rowA = params?.nodeA?.data as DetailRow | undefined
|
||||
const rowB = params?.nodeB?.data as DetailRow | undefined
|
||||
@ -322,7 +315,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
editable: false,
|
||||
valueGetter: params => calcServiceFee(params.data),
|
||||
aggFunc: decimalAggSum,
|
||||
// valueFormatter: formatEditableNumber
|
||||
valueFormatter: formatReadonlyNumber
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
@ -353,6 +346,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
if (!isWorkloadMethodApplicable.value) return
|
||||
if (shouldSkipPersist()) return
|
||||
try {
|
||||
const payload = {
|
||||
@ -367,6 +361,11 @@ const saveToIndexedDB = async () => {
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
if (!isWorkloadMethodApplicable.value) {
|
||||
detailRows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldForceDefaultLoad()) {
|
||||
detailRows.value = buildDefaultRows()
|
||||
return
|
||||
@ -451,11 +450,11 @@ const mydiyTheme = myTheme.withParams({
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">工作流规模</h3>
|
||||
<h3 class="text-sm font-semibold text-foreground">工作量明细</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<div v-if="isWorkloadMethodApplicable" class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows"
|
||||
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
|
||||
:enableCellSpan="true"
|
||||
@ -469,6 +468,15 @@ const mydiyTheme = myTheme.withParams({
|
||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
|
||||
>
|
||||
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
|
||||
<p class="text-lg font-semibold tracking-wide text-neutral-900">该服务不适用工作量法</p>
|
||||
<p class="mt-2 text-sm leading-6 text-red-700">当前服务没有关联工作量法任务,无需填写此部分内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -384,6 +384,11 @@ const processCellFromClipboard = (params:any) => {
|
||||
}
|
||||
return params.value;
|
||||
};
|
||||
|
||||
const scrollToGridSection = () => {
|
||||
const target = gridSectionRef.value || agGridRef.value
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -409,7 +414,12 @@ const processCellFromClipboard = (params:any) => {
|
||||
:style="{ height: `${agGridHeight}px` }"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">项目明细</h3>
|
||||
<h3
|
||||
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"
|
||||
@click="scrollToGridSection"
|
||||
>
|
||||
项目明细
|
||||
</h3>
|
||||
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||
</div>
|
||||
|
||||
@ -440,4 +450,3 @@ const processCellFromClipboard = (params:any) => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -571,6 +571,11 @@ const handleCellValueChanged = () => {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const scrollToGridSection = () => {
|
||||
const target = gridSectionRef.value || agGridRef.value
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
bindSnapScrollHost()
|
||||
@ -668,7 +673,12 @@ onBeforeUnmount(() => {
|
||||
class="rounded-lg border bg-card xmMx scroll-mt-3" :style="{ height: `${agGridHeight}px` }"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">咨询服务明细</h3>
|
||||
<h3
|
||||
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"
|
||||
@click="scrollToGridSection"
|
||||
>
|
||||
咨询服务明细
|
||||
</h3>
|
||||
<div class="text-xs text-muted-foreground">按服务词典生成</div>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user