This commit is contained in:
wintsa 2026-02-27 18:25:19 +08:00
parent 9849801e46
commit e97707ac59
6 changed files with 153 additions and 59 deletions

View File

@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { useTabStore } from '@/pinia/tab' 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 { import {
ToastAction, ToastAction,
ToastDescription, ToastDescription,
@ -51,16 +51,19 @@ let baseOffsetX = 0
let baseOffsetY = 0 let baseOffsetY = 0
const contractListScrollWrapRef = ref<HTMLElement | null>(null) const contractListScrollWrapRef = ref<HTMLElement | null>(null)
const contractListViewportRef = ref<HTMLElement | null>(null) const contractListViewportRef = ref<HTMLElement | null>(null)
const showScrollTopFab = ref(false)
const isDraggingContracts = ref(false) const isDraggingContracts = ref(false)
const cardMotionState = ref<'enter' | 'ready'>('ready') const cardMotionState = ref<'enter' | 'ready'>('ready')
let contractAutoScrollRaf = 0 let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null let dragPointerClientY: number | null = null
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
let contractListScrollBoundEl: HTMLElement | null = null
const CARD_ENTER_STEP_MS = 58 const CARD_ENTER_STEP_MS = 58
const CARD_ENTER_DURATION_MS = 560 const CARD_ENTER_DURATION_MS = 560
const CARD_ENTER_MAX_INDEX = 24 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 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[] => [ 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 = () => { const triggerCardEnterAnimation = () => {
if (cardEnterTimer) { if (cardEnterTimer) {
clearTimeout(cardEnterTimer) clearTimeout(cardEnterTimer)
@ -379,19 +427,20 @@ onMounted(async () => {
await loadContracts() await loadContracts()
triggerCardEnterAnimation() triggerCardEnterAnimation()
await nextTick() await nextTick()
getContractListViewport() bindContractListScroll()
}) })
onActivated(() => { onActivated(() => {
triggerCardEnterAnimation() triggerCardEnterAnimation()
void nextTick(() => { void nextTick(() => {
getContractListViewport() bindContractListScroll()
}) })
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopDrag() stopDrag()
stopContractAutoScroll() stopContractAutoScroll()
unbindContractListScroll()
if (cardEnterTimer) clearTimeout(cardEnterTimer) if (cardEnterTimer) clearTimeout(cardEnterTimer)
void saveContracts() void saveContracts()
}) })
@ -410,13 +459,29 @@ onBeforeUnmount(() => {
</Button> </Button>
</div> </div>
<div class="flex flex-col gap-2 md:flex-row md:items-center"> <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 <input
v-model="contractSearchKeyword" v-model="contractSearchKeyword"
type="text" type="text"
placeholder="搜索合同段名称或ID" 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" 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"> <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"> <label class="inline-flex cursor-pointer items-center gap-2 text-xs text-muted-foreground select-none">
<span>{{ isListLayout ? '列表布局' : '网格布局' }}</span> <span>{{ isListLayout ? '列表布局' : '网格布局' }}</span>
@ -434,17 +499,6 @@ onBeforeUnmount(() => {
/> />
</button> </button>
</label> </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> </div>
</div> </div>
@ -664,6 +718,19 @@ onBeforeUnmount(() => {
</ScrollArea> </ScrollArea>
</div> </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 <div
v-if="showCreateModal" v-if="showCreateModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"

View File

@ -5,7 +5,7 @@ import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql' import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' 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 { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@ -234,18 +234,18 @@ const formatMajorFactor = (params: any) => {
const formatReadonlyNumber = (params: any) => { const formatReadonlyNumber = (params: any) => {
if (params.value == null || params.value === '') return '' 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 getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
const result = getBasicFeeFromScale(row?.amount, 'cost') 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 getBudgetFee = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor'>) => {
const benchmarkBudget = getBenchmarkBudgetByAmount(row) const benchmarkBudget = getBenchmarkBudgetByAmount(row)
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null 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>[] = [ const columnDefs: ColDef<DetailRow>[] = [

View File

@ -5,7 +5,7 @@ import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql' import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' 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 { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@ -237,18 +237,18 @@ const formatMajorFactor = (params: any) => {
const formatReadonlyNumber = (params: any) => { const formatReadonlyNumber = (params: any) => {
if (params.value == null || params.value === '') return '' 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 getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) => {
const result = getBasicFeeFromScale(row?.landArea, 'area') 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 getBudgetFee = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor'>) => {
const benchmarkBudget = getBenchmarkBudgetByLandArea(row) const benchmarkBudget = getBenchmarkBudgetByLandArea(row)
if (benchmarkBudget == null || row?.majorFactor == null || row?.consultCategoryFactor == null) return null 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) => { const formatEditableFlexibleNumber = (params: any) => {

View File

@ -5,7 +5,7 @@ import type { ColDef } from 'ag-grid-community'
import localforage from 'localforage' import localforage from 'localforage'
import { serviceList, taskList } from '@/sql' import { serviceList, taskList } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum } from '@/lib/decimal' import { decimalAggSum, roundTo, toDecimal } from '@/lib/decimal'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
@ -91,14 +91,20 @@ const formatTaskReferenceUnitPrice = (task: taskLite) => {
return '' return ''
} }
const buildDefaultRows = (): DetailRow[] => { const getSourceTaskIds = () => {
const rows: DetailRow[] = []
const currentServiceId = Number(props.serviceId) 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) .filter(([, task]) => Number(task.serviceID) === currentServiceId)
.map(([key]) => Number(key)) .map(([key]) => Number(key))
.filter(Number.isFinite) .filter(Number.isFinite)
.sort((a, b) => a - b) .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()) { for (const [order, taskId] of sourceTaskIds.entries()) {
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)] 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 return rows
} }
@ -194,7 +181,7 @@ const calcServiceFee = (row: DetailRow | undefined) => {
) { ) {
return null return null
} }
return price * conversion * workload * factor return roundTo(toDecimal(price).mul(conversion).mul(workload).mul(factor), 2)
} }
const formatEditableNumber = (params: any) => { const formatEditableNumber = (params: any) => {
@ -206,6 +193,12 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2) 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 spanRowsByTaskName = (params: any) => {
const rowA = params?.nodeA?.data as DetailRow | undefined const rowA = params?.nodeA?.data as DetailRow | undefined
const rowB = params?.nodeB?.data as DetailRow | undefined const rowB = params?.nodeB?.data as DetailRow | undefined
@ -322,7 +315,7 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: false, editable: false,
valueGetter: params => calcServiceFee(params.data), valueGetter: params => calcServiceFee(params.data),
aggFunc: decimalAggSum, aggFunc: decimalAggSum,
// valueFormatter: formatEditableNumber valueFormatter: formatReadonlyNumber
}, },
{ {
headerName: '说明', headerName: '说明',
@ -353,6 +346,7 @@ const columnDefs: ColDef<DetailRow>[] = [
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
if (!isWorkloadMethodApplicable.value) return
if (shouldSkipPersist()) return if (shouldSkipPersist()) return
try { try {
const payload = { const payload = {
@ -367,6 +361,11 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => { const loadFromIndexedDB = async () => {
try { try {
if (!isWorkloadMethodApplicable.value) {
detailRows.value = []
return
}
if (shouldForceDefaultLoad()) { if (shouldForceDefaultLoad()) {
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows()
return 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="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"> <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 class="text-xs text-muted-foreground">导入导出</div>
</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" <AgGridVue :style="{ height: '100%' }" :rowData="detailRows"
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false" :columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
:enableCellSpan="true" :enableCellSpan="true"
@ -469,6 +468,15 @@ const mydiyTheme = myTheme.withParams({
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard" :processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" /> :undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div> </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>
</div> </div>
</template> </template>

View File

@ -384,6 +384,11 @@ const processCellFromClipboard = (params:any) => {
} }
return params.value; return params.value;
}; };
const scrollToGridSection = () => {
const target = gridSectionRef.value || agGridRef.value
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script> </script>
<template> <template>
@ -409,7 +414,12 @@ const processCellFromClipboard = (params:any) => {
:style="{ height: `${agGridHeight}px` }" :style="{ height: `${agGridHeight}px` }"
> >
<div class="flex items-center justify-between border-b px-4 py-3"> <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 class="text-xs text-muted-foreground">导入导出</div>
</div> </div>
@ -440,4 +450,3 @@ const processCellFromClipboard = (params:any) => {
</div> </div>
</div> </div>
</template> </template>

View File

@ -571,6 +571,11 @@ const handleCellValueChanged = () => {
}, 1000) }, 1000)
} }
const scrollToGridSection = () => {
const target = gridSectionRef.value || agGridRef.value
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
onMounted(async () => { onMounted(async () => {
await loadFromIndexedDB() await loadFromIndexedDB()
bindSnapScrollHost() bindSnapScrollHost()
@ -668,7 +673,12 @@ onBeforeUnmount(() => {
class="rounded-lg border bg-card xmMx scroll-mt-3" :style="{ height: `${agGridHeight}px` }" 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"> <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 class="text-xs text-muted-foreground">按服务词典生成</div>
</div> </div>