fix 拖动流畅度
This commit is contained in:
parent
37f4a99914
commit
57a2029847
33
src/components/ui/tooltip/TooltipContent.vue
Normal file
33
src/components/ui/tooltip/TooltipContent.vue
Normal 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>
|
||||||
2
src/components/ui/tooltip/index.ts
Normal file
2
src/components/ui/tooltip/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as TooltipContent } from './TooltipContent.vue'
|
||||||
|
export { TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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: '基准预算(元)',
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"}
|
||||||
Loading…
x
Reference in New Issue
Block a user