fix 拖动流畅度

This commit is contained in:
wintsa 2026-02-26 18:04:36 +08:00
parent 37f4a99914
commit 57a2029847
13 changed files with 576 additions and 64 deletions

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent as RekaTooltipContent, TooltipPortal } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 6
}
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TooltipPortal>
<RekaTooltipContent
v-bind="delegatedProps"
:class="
cn(
'z-[90] rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md',
props.class
)
"
>
<slot />
<TooltipArrow class="fill-popover" />
</RekaTooltipContent>
</TooltipPortal>
</template>

View File

@ -0,0 +1,2 @@
export { default as TooltipContent } from './TooltipContent.vue'
export { TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'

View File

@ -3,6 +3,8 @@
<TypeLine <TypeLine
scene="ht-tab" scene="ht-tab"
:title="`合同段:${contractName}`" :title="`合同段:${contractName}`"
:subtitle="`合同段ID${contractId}`"
:copy-text="contractId"
:storage-key="`project-active-cat-${contractId}`" :storage-key="`project-active-cat-${contractId}`"
default-category="info" default-category="info"
:categories="xmCategories" :categories="xmCategories"

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import localforage from 'localforage' import localforage from 'localforage'
import { Card, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' 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 { useTabStore } from '@/pinia/tab'
import { Edit3, Plus, Trash2, X } from 'lucide-vue-next' import { Edit3, GripVertical, Plus, Trash2, X } from 'lucide-vue-next'
import { import {
ToastAction, ToastAction,
ToastDescription, ToastDescription,
@ -33,6 +35,8 @@ const tabStore = useTabStore()
const contracts = ref<ContractItem[]>([]) const contracts = ref<ContractItem[]>([])
const contractSearchKeyword = ref('')
const isListLayout = ref(false)
const showCreateModal = ref(false) const showCreateModal = ref(false)
const contractNameInput = ref('') const contractNameInput = ref('')
@ -45,11 +49,27 @@ let dragStartX = 0
let dragStartY = 0 let dragStartY = 0
let baseOffsetX = 0 let baseOffsetX = 0
let baseOffsetY = 0 let baseOffsetY = 0
const contractListScrollWrapRef = ref<HTMLElement | null>(null)
const contractListViewportRef = ref<HTMLElement | null>(null)
const isDraggingContracts = ref(false)
let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null
const buildDefaultContracts = (): ContractItem[] => [ const buildDefaultContracts = (): ContractItem[] => [
] ]
const normalizedSearchKeyword = computed(() => contractSearchKeyword.value.trim().toLowerCase())
const filteredContracts = computed(() => {
if (!normalizedSearchKeyword.value) return contracts.value
return contracts.value.filter(item => {
const name = item.name.toLowerCase()
const id = item.id.toLowerCase()
return name.includes(normalizedSearchKeyword.value) || id.includes(normalizedSearchKeyword.value)
})
})
const isSearchingContracts = computed(() => Boolean(normalizedSearchKeyword.value))
const normalizeOrder = (list: ContractItem[]): ContractItem[] => const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({ list.map((item, index) => ({
...item, ...item,
@ -74,6 +94,23 @@ const notify = (text: string) => {
}) })
} }
const getContractListViewport = () => {
const viewport = contractListScrollWrapRef.value?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
) || null
contractListViewportRef.value = viewport
return viewport
}
const scrollContractsToBottom = (behavior: ScrollBehavior = 'smooth') => {
const viewport = getContractListViewport()
if (!viewport) return
viewport.scrollTo({
top: viewport.scrollHeight,
behavior
})
}
const saveContracts = async () => { const saveContracts = async () => {
try { try {
contracts.value = normalizeOrder(contracts.value) contracts.value = normalizeOrder(contracts.value)
@ -189,6 +226,8 @@ const createContract = async () => {
await saveContracts() await saveContracts()
notify('新建成功') notify('新建成功')
closeCreateModal() closeCreateModal()
await nextTick()
scrollContractsToBottom()
} }
const deleteContract = async (id: string) => { const deleteContract = async (id: string) => {
@ -206,6 +245,7 @@ const deleteContract = async (id: string) => {
} }
const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => { const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => {
stopContractAutoScroll()
if ( if (
event.oldIndex == null || event.oldIndex == null ||
event.newIndex == null || event.newIndex == null ||
@ -218,6 +258,65 @@ const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) =>
notify('排序完成') notify('排序完成')
} }
const updateDragPointerPosition = (event: MouseEvent | DragEvent) => {
if (!isDraggingContracts.value) return
dragPointerClientY = event.clientY
}
const contractAutoScrollTick = () => {
if (!isDraggingContracts.value) {
contractAutoScrollRaf = 0
return
}
const viewport = contractListViewportRef.value || getContractListViewport()
const clientY = dragPointerClientY
if (viewport && clientY != null) {
const rect = viewport.getBoundingClientRect()
const edge = 88
const maxStep = 22
let delta = 0
if (clientY < rect.top + edge) {
const ratio = Math.max(0, Math.min(1, (rect.top + edge - clientY) / edge))
delta = -Math.ceil(maxStep * ratio)
} else if (clientY > rect.bottom - edge) {
const ratio = Math.max(0, Math.min(1, (clientY - (rect.bottom - edge)) / edge))
delta = Math.ceil(maxStep * ratio)
}
if (delta !== 0) {
viewport.scrollTop = Math.max(
0,
Math.min(viewport.scrollTop + delta, viewport.scrollHeight - viewport.clientHeight)
)
}
}
contractAutoScrollRaf = window.requestAnimationFrame(contractAutoScrollTick)
}
const startContractAutoScroll = () => {
getContractListViewport()
isDraggingContracts.value = true
dragPointerClientY = null
window.addEventListener('pointermove', updateDragPointerPosition as EventListener, { passive: true })
window.addEventListener('dragover', updateDragPointerPosition as EventListener, { passive: true })
if (contractAutoScrollRaf) cancelAnimationFrame(contractAutoScrollRaf)
contractAutoScrollRaf = window.requestAnimationFrame(contractAutoScrollTick)
}
const stopContractAutoScroll = () => {
isDraggingContracts.value = false
dragPointerClientY = null
window.removeEventListener('pointermove', updateDragPointerPosition as EventListener)
window.removeEventListener('dragover', updateDragPointerPosition as EventListener)
if (contractAutoScrollRaf) {
cancelAnimationFrame(contractAutoScrollRaf)
contractAutoScrollRaf = 0
}
}
const handleCardClick = (item: ContractItem) => { const handleCardClick = (item: ContractItem) => {
tabStore.openTab({ tabStore.openTab({
id: `contract-${item.id}`, id: `contract-${item.id}`,
@ -250,66 +349,279 @@ const startDrag = (event: MouseEvent) => {
onMounted(async () => { onMounted(async () => {
await loadContracts() await loadContracts()
await nextTick()
getContractListViewport()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopDrag() stopDrag()
stopContractAutoScroll()
void saveContracts() void saveContracts()
}) })
</script> </script>
<template> <template>
<ToastProvider> <ToastProvider>
<div> <TooltipProvider>
<div class="mb-6 flex items-center justify-between"> <div class="flex h-full min-h-0 flex-col overflow-hidden">
<h3 class="text-lg font-bold">合同段列表</h3> <div class="shrink-0 border-b bg-background/95 px-1 pb-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<Button @click="openCreateModal"> <div class="mb-6 flex items-center justify-between pt-1">
<Plus class="mr-2 h-4 w-4" /> <h3 class="text-lg font-bold">合同段列表</h3>
添加合同段 <Button @click="openCreateModal">
</Button> <Plus class="mr-2 h-4 w-4" />
添加合同段
</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-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>
<button
type="button"
role="switch"
:aria-checked="isListLayout"
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors active:scale-95"
:class="isListLayout ? 'bg-primary border-primary' : 'bg-muted border-border'"
@click="isListLayout = !isListLayout"
>
<span
class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-background shadow-sm transition-all"
:class="isListLayout ? 'left-6' : 'left-1'"
/>
</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> </div>
<draggable <div ref="contractListScrollWrapRef" class="mt-4 flex-1 min-h-0">
v-model="contracts" <ScrollArea :class="['ht-contract-scroll-area h-full', isDraggingContracts && 'is-dragging']">
item-key="id" <draggable
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" v-if="!isSearchingContracts"
animation="200" :key="`contracts-${isListLayout ? 'list' : 'grid'}`"
@end="handleDragEnd" v-model="contracts"
> item-key="id"
<template #item="{ element }"> handle=".contract-drag-handle"
ghost-class="ht-sortable-ghost"
chosen-class="ht-sortable-chosen"
drag-class="ht-sortable-drag"
:class="[
'grid grid-cols-1 pb-4 pr-4',
isListLayout ? 'gap-2' : 'gap-4',
!isListLayout && 'md:grid-cols-2 lg:grid-cols-3'
]"
animation="200"
@start="startContractAutoScroll"
@end="handleDragEnd"
>
<template #item="{ element }">
<Card
:class="[
'group cursor-pointer snap-start snap-always transition-colors hover:border-primary',
isListLayout && 'gap-0 py-0'
]"
@click="handleCardClick(element)"
>
<CardHeader
:class="[
'flex flex-row items-center justify-between gap-0 space-y-0',
isListLayout ? 'px-3 py-2' : 'pb-6'
]"
>
<CardTitle
:class="[
'text-sm font-medium',
isListLayout && 'mr-1.5 flex min-w-0 flex-1 items-center gap-1.5'
]"
>
<span :class="isListLayout ? 'min-w-0 truncate' : ''">{{ element.name }}</span>
<template v-if="isListLayout">
<span class="min-w-0 shrink text-[11px] leading-none font-normal text-muted-foreground truncate">
ID: {{ element.id }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间{{ formatDateTime(element.createdAt) }}
</span>
</template>
</CardTitle>
<div :class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']">
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
:class="[
'contract-drag-handle inline-flex cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-muted active:cursor-grabbing',
isListLayout ? 'h-6 w-6' : 'h-7 w-7'
]"
@click.stop
>
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</button>
</TooltipTrigger>
<TooltipContent side="top">拖动排序</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="isListLayout ? 'h-6 w-6' : 'h-7 w-7'"
@click.stop="openEditModal(element)"
>
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="deleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent>
</TooltipRoot>
</div>
</CardHeader>
<div
v-if="!isListLayout"
:class="[
'px-6 text-xs text-muted-foreground',
'space-y-1 '
]"
>
<div class="break-all">ID{{ element.id }}</div>
<div>创建时间{{ formatDateTime(element.createdAt) }}</div>
</div>
</Card>
</template>
</draggable>
<div
v-else
:key="`contracts-search-${isListLayout ? 'list' : 'grid'}`"
:class="[
'grid grid-cols-1 pb-4 pr-4',
isListLayout ? 'gap-2' : 'gap-4',
!isListLayout && 'md:grid-cols-2 lg:grid-cols-3'
]"
>
<Card <Card
class="group cursor-pointer transition-colors hover:border-primary" v-for="element in filteredContracts"
:key="element.id"
:class="[
'group cursor-pointer snap-start snap-always transition-colors hover:border-primary',
isListLayout && 'gap-0 py-0'
]"
@click="handleCardClick(element)" @click="handleCardClick(element)"
> >
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader
<CardTitle class="text-sm font-medium"> :class="[
<span class="ml-2">{{ element.name }}</span> 'flex flex-row items-center justify-between gap-0 space-y-0',
isListLayout ? 'px-3 py-2' : 'pb-2'
]"
>
<CardTitle
:class="[
'text-sm font-medium',
isListLayout && 'mr-1.5 flex min-w-0 flex-1 items-center gap-1.5'
]"
>
<span :class="isListLayout ? 'min-w-0 truncate' : ''">{{ element.name }}</span>
<template v-if="isListLayout">
<span class="min-w-0 shrink text-[11px] leading-none font-normal text-muted-foreground truncate">
ID: {{ element.id }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间{{ formatDateTime(element.createdAt) }}
</span>
</template>
</CardTitle> </CardTitle>
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div :class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']">
<Button <TooltipRoot>
variant="ghost" <TooltipTrigger as-child>
size="icon" <span
class="h-7 w-7" :class="[
@click.stop="openEditModal(element)" 'inline-flex items-center justify-center rounded-md text-muted-foreground',
> isListLayout ? 'h-6 w-6' : 'h-7 w-7'
<Edit3 class="h-4 w-4" /> ]"
</Button> >
<Button <GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
variant="ghost" </span>
size="icon" </TooltipTrigger>
class="h-7 w-7 text-destructive" <TooltipContent side="top">拖动排序搜索时关闭</TooltipContent>
@click.stop="deleteContract(element.id)" </TooltipRoot>
> <TooltipRoot>
<Trash2 class="h-4 w-4" /> <TooltipTrigger as-child>
</Button> <Button
variant="ghost"
size="icon"
:class="isListLayout ? 'h-6 w-6' : 'h-7 w-7'"
@click.stop="openEditModal(element)"
>
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="deleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent>
</TooltipRoot>
</div> </div>
</CardHeader> </CardHeader>
<div class="px-6 pb-4 text-xs text-muted-foreground"> <div
创建时间{{ formatDateTime(element.createdAt) }} v-if="!isListLayout"
:class="[
'px-6 text-xs text-muted-foreground',
'space-y-1 pb-4'
]"
>
<div class="break-all">ID{{ element.id }}</div>
<div>创建时间{{ formatDateTime(element.createdAt) }}</div>
</div> </div>
</Card> </Card>
</template> <div
</draggable> v-if="filteredContracts.length === 0"
class="col-span-full rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground"
>
未找到匹配的合同段
</div>
</div>
</ScrollArea>
</div>
<div <div
v-if="showCreateModal" v-if="showCreateModal"
@ -369,5 +681,28 @@ onBeforeUnmount(() => {
</ToastAction> </ToastAction>
</ToastRoot> </ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[80] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" /> <ToastViewport class="fixed bottom-5 right-5 z-[80] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
</TooltipProvider>
</ToastProvider> </ToastProvider>
</template> </template>
<style scoped>
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
}
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
scroll-snap-type: none;
}
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
opacity: 0.35;
}
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<TypeLine <TypeLine
scene="xm-tab" scene="xm-tab"
title="项目配置" title=""
storage-key="project-active-cat" storage-key="project-active-cat"
default-category="info" default-category="info"
:categories="xmCategories" :categories="xmCategories"

View File

@ -1,7 +1,9 @@
<template> <template>
<TypeLine <TypeLine
scene="zxfw-pricing-tab" scene="zxfw-pricing-tab"
title="咨询服务计算" :title="`${fwName}计算`"
:subtitle="`合同ID${contractId}`"
:copy-text="contractId"
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`" :storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
default-category="investment-scale-method" default-category="investment-scale-method"
:categories="pricingCategories" :categories="pricingCategories"
@ -16,6 +18,7 @@ const props = defineProps<{
contractId: string contractId: string
contractName?: string contractName?: string
serviceId: string|number serviceId: string|number
fwName:string
}>() }>()
interface PricingCategoryItem { interface PricingCategoryItem {

View File

@ -192,7 +192,7 @@ const columnDefs: ColDef<DetailRow>[] = [
return '点击输入' return '点击输入'
} }
if (params.value == null) return '' if (params.value == null) return ''
return Number(params.value).toFixed(2) return String(Number(params.value))
} }
} }
] ]

View File

@ -171,6 +171,14 @@ const formatEditableNumber = (params: any) => {
return Number(params.value).toFixed(2) return Number(params.value).toFixed(2)
} }
const formatEditableFlexibleNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return String(Number(params.value))
}
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ {
headerName: '工程专业名称', headerName: '工程专业名称',
@ -196,7 +204,7 @@ const columnDefs: ColDef<DetailRow>[] = [
}, },
aggFunc: 'sum', aggFunc: 'sum',
valueParser: params => parseNumberOrNull(params.newValue), valueParser: params => parseNumberOrNull(params.newValue),
valueFormatter: formatEditableNumber valueFormatter: formatEditableFlexibleNumber
}, },
{ {
headerName: '基准预算(元)', headerName: '基准预算(元)',

View File

@ -62,7 +62,8 @@ const updateGridCardHeight = () => {
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0 const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0 const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom)) const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight agGridHeight.value = nextHeight+10
} }
const bindSnapScrollHost = () => { const bindSnapScrollHost = () => {
@ -256,7 +257,7 @@ const columnDefs: ColDef<DetailRow>[] = [
return '点击输入' return '点击输入'
} }
if (params.value == null) return '' if (params.value == null) return ''
return Number(params.value).toFixed(2) return String(Number(params.value))
} }
} }
] ]
@ -324,7 +325,6 @@ const saveToIndexedDB = async () => {
projectName: projectName.value, projectName: projectName.value,
detailRows: JSON.parse(JSON.stringify(detailRows.value)) detailRows: JSON.parse(JSON.stringify(detailRows.value))
} }
console.log(payload)
await localforage.setItem(DB_KEY, payload) await localforage.setItem(DB_KEY, payload)
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
@ -407,28 +407,32 @@ const processCellFromClipboard = (params:any) => {
<template> <template>
<div ref="rootRef" class="space-y-6"> <div ref="rootRef" class="space-y-6">
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0"> <div class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
<div class="mb-2 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-foreground">项目名称</label> <div>
<label class="block text-sm font-medium text-foreground">项目名称</label>
<p class="mt-1 text-xs text-muted-foreground">该名称会用于项目级计算与展示</p>
</div>
</div> </div>
<input <input
v-model="projectName" v-model="projectName"
type="text" type="text"
placeholder="请输入项目名称" placeholder="请输入项目名称"
class="h-10 w-full max-w-xl rounded-md border bg-background px-3 text-sm outline-none ring-offset-background transition focus-visible:ring-2 focus-visible:ring-ring" class="h-10 w-full max-w-6xl 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"
/> />
</div> </div>
<div <div
ref="gridSectionRef" ref="gridSectionRef"
class="rounded-lg border bg-card xmMx scroll-mt-3" class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden"
: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">项目明细</h3>
<div class="text-xs text-muted-foreground">导入导出</div> <div class="text-xs text-muted-foreground">导入导出</div>
</div> </div>
<div ref="agGridRef" class="ag-theme-quartz w-full" :style="{ height: `${agGridHeight}px` }"> <div ref="agGridRef" class="ag-theme-quartz w-full flex-1 min-h-0">
<AgGridVue <AgGridVue
:style="{ height: '100%' }" :style="{ height: '100%' }"
:rowData="detailRows" :rowData="detailRows"

View File

@ -90,9 +90,10 @@ const updateGridCardHeight = () => {
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0 const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0 const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom)) const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
agGridHeight.value = nextHeight agGridHeight.value = nextHeight-20
} }
const bindSnapScrollHost = () => { const bindSnapScrollHost = () => {
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
if (!snapScrollHost) return if (!snapScrollHost) return
@ -226,7 +227,7 @@ const openEditTab = (row: DetailRow) => {
id: `zxfw-edit-${props.contractId}-${row.id}`, id: `zxfw-edit-${props.contractId}-${row.id}`,
title: `服务编辑-${row.code}${row.name}`, title: `服务编辑-${row.code}${row.name}`,
componentName: 'ZxFwView', componentName: 'ZxFwView',
props: { contractId: props.contractId, contractName: row.name ,serviceId: row.id} props: { contractId: props.contractId, contractName: row.name ,serviceId: row.id,fwName:row.code+row.name}
}) })
} }
@ -251,7 +252,7 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: false, editable: false,
valueParser: params => numericParser(params.newValue), valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2)) valueFormatter: params => (params.value == null ? '' : String(Number(params.value)))
}, },
{ {
headerName: '工作量法', headerName: '工作量法',
@ -643,14 +644,14 @@ onBeforeUnmount(() => {
<div <div
ref="gridSectionRef" ref="gridSectionRef"
class="rounded-lg border bg-card xmMx scroll-mt-3" 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">咨询服务明细</h3>
<div class="text-xs text-muted-foreground">按服务词典生成</div> <div class="text-xs text-muted-foreground">按服务词典生成</div>
</div> </div>
<div ref="agGridRef" class="ag-theme-quartz w-full" :style="{ height: `${agGridHeight}px` }"> <div ref="agGridRef" class="ag-theme-quartz w-full h-full" >
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="gridOptions" <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="gridOptions"
:theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true" :theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true"

View File

@ -56,6 +56,7 @@ const dataMenuOpen = ref(false)
const dataMenuRef = ref<HTMLElement | null>(null) const dataMenuRef = ref<HTMLElement | null>(null)
const importFileRef = ref<HTMLInputElement | null>(null) const importFileRef = ref<HTMLInputElement | null>(null)
const tabItemElMap = new Map<string, HTMLElement>() const tabItemElMap = new Map<string, HTMLElement>()
const tabPanelElMap = new Map<string, HTMLElement>()
const tabsModel = computed({ const tabsModel = computed({
get: () => tabStore.tabs, get: () => tabStore.tabs,
@ -125,6 +126,14 @@ const setTabItemRef = (id: string, el: Element | ComponentPublicInstance | null)
tabItemElMap.delete(id) tabItemElMap.delete(id)
} }
const setTabPanelRef = (id: string, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
tabPanelElMap.set(id, el)
return
}
tabPanelElMap.delete(id)
}
const ensureActiveTabVisible = () => { const ensureActiveTabVisible = () => {
const activeId = tabStore.activeTabId const activeId = tabStore.activeTabId
const el = tabItemElMap.get(activeId) const el = tabItemElMap.get(activeId)
@ -132,6 +141,50 @@ const ensureActiveTabVisible = () => {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
} }
const getActivePanelScrollViewport = (tabId?: string | null) => {
if (!tabId) return null
const panelEl = tabPanelElMap.get(tabId)
if (!panelEl) return null
return panelEl.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]')
}
const getTabScrollSessionKey = (tabId: string) => `tab-scroll-top:${tabId}`
const saveTabInnerScrollTop = (tabId?: string | null) => {
if (!tabId) return
const viewport = getActivePanelScrollViewport(tabId)
if (!viewport) return
const top = viewport.scrollTop || 0
try {
sessionStorage.setItem(getTabScrollSessionKey(tabId), String(top))
} catch (error) {
console.error('save tab scroll failed:', error)
}
}
const restoreTabInnerScrollTop = (tabId?: string | null) => {
if (!tabId) return
const viewport = getActivePanelScrollViewport(tabId)
if (!viewport) return
let top = 0
try {
top = Number(sessionStorage.getItem(getTabScrollSessionKey(tabId)) || '0') || 0
} catch (error) {
console.error('restore tab scroll failed:', error)
}
viewport.scrollTop = top
}
const scheduleRestoreTabInnerScrollTop = (tabId?: string | null) => {
if (!tabId) return
requestAnimationFrame(() => {
restoreTabInnerScrollTop(tabId)
requestAnimationFrame(() => {
restoreTabInnerScrollTop(tabId)
})
})
}
const readWebStorage = (storageObj: Storage): DataEntry[] => { const readWebStorage = (storageObj: Storage): DataEntry[] => {
const entries: DataEntry[] = [] const entries: DataEntry[] = []
for (let i = 0; i < storageObj.length; i++) { for (let i = 0; i < storageObj.length; i++) {
@ -251,6 +304,7 @@ onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown) window.addEventListener('mousedown', handleGlobalMouseDown)
void nextTick(() => { void nextTick(() => {
ensureActiveTabVisible() ensureActiveTabVisible()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
}) })
}) })
@ -260,12 +314,31 @@ onBeforeUnmount(() => {
watch( watch(
() => tabStore.activeTabId, () => tabStore.activeTabId,
() => { (nextId, prevId) => {
saveTabInnerScrollTop(prevId)
void nextTick(() => { void nextTick(() => {
ensureActiveTabVisible() ensureActiveTabVisible()
scheduleRestoreTabInnerScrollTop(nextId)
}) })
} }
) )
watch(
() => tabStore.tabs.map(t => t.id),
(ids) => {
const idSet = new Set(ids)
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i)
if (!key || !key.startsWith('tab-scroll-top:')) continue
const tabId = key.slice('tab-scroll-top:'.length)
if (!idSet.has(tabId)) sessionStorage.removeItem(key)
}
} catch (error) {
console.error('cleanup tab scroll cache failed:', error)
}
}
)
</script> </script>
<template> <template>
@ -372,6 +445,7 @@ watch(
<div <div
v-for="tab in tabStore.tabs" v-for="tab in tabStore.tabs"
:key="tab.id" :key="tab.id"
:ref="el => setTabPanelRef(tab.id, el)"
v-show="tabStore.activeTabId === tab.id" v-show="tabStore.activeTabId === tab.id"
class="h-full w-full p-6 animate-in fade-in duration-300" class="h-full w-full p-6 animate-in fade-in duration-300"
> >

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, type Component } from 'vue' import { computed, onBeforeUnmount, ref, watch, type Component } from 'vue'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
interface TypeLineCategory { interface TypeLineCategory {
key: string key: string
@ -12,6 +13,8 @@ const props = withDefaults(
defineProps<{ defineProps<{
scene?: string scene?: string
title?: string title?: string
subtitle?: string
copyText?: string
categories: TypeLineCategory[] categories: TypeLineCategory[]
storageKey?: string storageKey?: string
defaultCategory?: string defaultCategory?: string
@ -19,6 +22,8 @@ const props = withDefaults(
{ {
scene: 'default', scene: 'default',
title: '配置', title: '配置',
subtitle: '',
copyText: '',
storageKey: '', storageKey: '',
defaultCategory: '' defaultCategory: ''
} }
@ -52,12 +57,57 @@ const activeComponent = computed(() => {
const selected = props.categories.find(item => item.key === activeCategory.value) const selected = props.categories.find(item => item.key === activeCategory.value)
return selected?.component || props.categories[0]?.component || null return selected?.component || props.categories[0]?.component || null
}) })
const copyBtnText = ref('复制')
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
const handleCopySubtitle = async () => {
const text = (props.copyText || '').trim()
if (!text) return
try {
await navigator.clipboard.writeText(text)
copyBtnText.value = '已复制'
} catch (error) {
console.error('copy failed:', error)
copyBtnText.value = '复制失败'
}
if (copyBtnTimer) clearTimeout(copyBtnTimer)
copyBtnTimer = setTimeout(() => {
copyBtnText.value = '复制'
}, 1200)
}
onBeforeUnmount(() => {
if (copyBtnTimer) clearTimeout(copyBtnTimer)
})
</script> </script>
<template> <template>
<div class="flex h-full w-full bg-background"> <div class="flex h-full w-full bg-background">
<div class="w-12/100 border-r p-6 flex flex-col gap-8 relative"> <div class="w-12/100 border-r p-6 flex flex-col gap-8 relative">
<!-- <div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div> --> <div v-if="props.title || props.subtitle" class="space-y-1">
<div v-if="props.title" class="font-bold text-base leading-6 text-primary break-words">
{{ props.title }}
</div>
<div
v-if="props.subtitle"
class="flex flex-wrap items-center gap-2 text-xs leading-5 text-muted-foreground"
>
<span class="break-all">{{ props.subtitle }}</span>
<Button
v-if="props.copyText"
type="button"
variant="outline"
size="sm"
class="h-6 rounded-md px-2 text-[11px]"
@click.stop="handleCopySubtitle"
>
{{ copyBtnText }}
</Button>
</div>
</div>
<div class="flex flex-col gap-10 relative"> <div class="flex flex-col gap-10 relative">
<div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div> <div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/lib/utils.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/views/ht.vue","./src/components/views/xm.vue","./src/components/views/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"} {"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/lib/diyaggridtheme.ts","./src/lib/utils.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/views/contractdetailview.vue","./src/components/views/ht.vue","./src/components/views/xm.vue","./src/components/views/zxfwview.vue","./src/components/views/htinfo.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}