fix more
This commit is contained in:
parent
f121aa233e
commit
5734cfa534
@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="./public/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>my-vue-app</title>
|
<title>造价计算工具</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@ -4,7 +4,7 @@ import { cva } from "class-variance-authority"
|
|||||||
export { default as Button } from "./Button.vue"
|
export { default as Button } from "./Button.vue"
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
60
src/components/views/ContractDetailView.vue
Normal file
60
src/components/views/ContractDetailView.vue
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 修复模板字符串语法(反引号需用 v-bind 或模板插值)+ 补充属性格式 -->
|
||||||
|
<TypeLine
|
||||||
|
scene="ht-tab"
|
||||||
|
:title="`合同段:${contractName}`"
|
||||||
|
storage-key="project-active-cat"
|
||||||
|
default-category="info"
|
||||||
|
:categories="xmCategories"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { markRaw, defineAsyncComponent, defineComponent, h, type Component } from 'vue';
|
||||||
|
import TypeLine from '@/layout/typeLine.vue';
|
||||||
|
|
||||||
|
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string; // 合同ID(必传)
|
||||||
|
contractName: string; // 合同名称(必传)
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
|
||||||
|
interface XmCategoryItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
component: Component; // 标记为 Vue 组件类型
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 优化异步组件导入(添加加载失败兜底 + 类型标注)
|
||||||
|
const htView = markRaw(
|
||||||
|
defineComponent({
|
||||||
|
name: 'HtInfoWithProps',
|
||||||
|
setup() {
|
||||||
|
const AsyncHtInfo = defineAsyncComponent({
|
||||||
|
loader: () => import('@/components/views/htInfo.vue'),
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('加载 htInfo 组件失败:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => h(AsyncHtInfo, { contractId: props.contractId });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const zxfwView = markRaw(
|
||||||
|
defineAsyncComponent({
|
||||||
|
loader: () => import('@/components/views/zxFw.vue'),
|
||||||
|
// 可选:加载失败时的兜底组件
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('加载 Ht 组件失败:', err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 给分类数组添加严格类型标注
|
||||||
|
const xmCategories: XmCategoryItem[] = [
|
||||||
|
{ key: 'info', label: '规模信息', component: htView },
|
||||||
|
{ key: 'contract', label: '咨询服务', component: zxfwView }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
@ -1,70 +1,292 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
|
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 { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { Plus, Trash2, Edit3, ExternalLink } from 'lucide-vue-next'
|
import { Edit3, Plus, Trash2, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
interface ContractItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
order: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ht-card-v1'
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
|
|
||||||
const contracts = ref([
|
|
||||||
{ id: 'ct-1', name: '土建一标段' },
|
|
||||||
{ id: 'ct-2', name: '绿化配套标段' }
|
|
||||||
])
|
|
||||||
|
|
||||||
// 添加合同
|
|
||||||
const addContract = () => {
|
const contracts = ref<ContractItem[]>([])
|
||||||
const newId = `ct-${Date.now()}`
|
|
||||||
contracts.value.push({ id: newId, name: '新建合同段' })
|
const showCreateModal = ref(false)
|
||||||
|
const contractNameInput = ref('')
|
||||||
|
const editingContractId = ref<string | null>(null)
|
||||||
|
const toastText = ref('')
|
||||||
|
const showToast = ref(false)
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const modalOffset = ref({ x: 0, y: 0 })
|
||||||
|
let dragStartX = 0
|
||||||
|
let dragStartY = 0
|
||||||
|
let baseOffsetX = 0
|
||||||
|
let baseOffsetY = 0
|
||||||
|
|
||||||
|
const buildDefaultContracts = (): ContractItem[] => [
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
|
||||||
|
list.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
order: index,
|
||||||
|
createdAt: item.createdAt || new Date().toISOString()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formatDateTime = (value: string) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return '-'
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除合同
|
const notify = (text: string) => {
|
||||||
const deleteContract = (id: string) => {
|
toastText.value = text
|
||||||
contracts.value = contracts.value.filter(c => c.id !== id)
|
showToast.value = true
|
||||||
|
if (toastTimer) clearTimeout(toastTimer)
|
||||||
|
toastTimer = setTimeout(() => {
|
||||||
|
showToast.value = false
|
||||||
|
}, 1600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 【关键逻辑】点击进入详细页面,打开新 Tab
|
const saveContracts = async () => {
|
||||||
const goToDetail = (item: any) => {
|
try {
|
||||||
|
contracts.value = normalizeOrder(contracts.value)
|
||||||
|
await localforage.setItem(STORAGE_KEY, JSON.parse(JSON.stringify(contracts.value)))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('save contracts failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadContracts = async () => {
|
||||||
|
try {
|
||||||
|
const saved = await localforage.getItem<ContractItem[]>(STORAGE_KEY)
|
||||||
|
if (!saved || saved.length === 0) {
|
||||||
|
contracts.value = buildDefaultContracts()
|
||||||
|
await saveContracts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...saved].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
contracts.value = normalizeOrder(sorted)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('load contracts failed:', error)
|
||||||
|
contracts.value = buildDefaultContracts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
editingContractId.value = null
|
||||||
|
contractNameInput.value = ''
|
||||||
|
modalOffset.value = { x: 0, y: 0 }
|
||||||
|
showCreateModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (item: ContractItem) => {
|
||||||
|
editingContractId.value = item.id
|
||||||
|
contractNameInput.value = item.name
|
||||||
|
modalOffset.value = { x: 0, y: 0 }
|
||||||
|
showCreateModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCreateModal = () => {
|
||||||
|
showCreateModal.value = false
|
||||||
|
editingContractId.value = null
|
||||||
|
contractNameInput.value = ''
|
||||||
|
modalOffset.value = { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const createContract = async () => {
|
||||||
|
const name = contractNameInput.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
if (editingContractId.value) {
|
||||||
|
contracts.value = contracts.value.map(item =>
|
||||||
|
item.id === editingContractId.value ? { ...item, name } : item
|
||||||
|
)
|
||||||
|
await saveContracts()
|
||||||
|
notify('编辑成功')
|
||||||
|
closeCreateModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contracts.value = [
|
||||||
|
...contracts.value,
|
||||||
|
{
|
||||||
|
id: `ct-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
||||||
|
name,
|
||||||
|
order: contracts.value.length,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
await saveContracts()
|
||||||
|
notify('新建成功')
|
||||||
|
closeCreateModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteContract = async (id: string) => {
|
||||||
|
contracts.value = contracts.value.filter(item => item.id !== id)
|
||||||
|
await saveContracts()
|
||||||
|
notify('删除成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = async () => {
|
||||||
|
await saveContracts()
|
||||||
|
notify('排序完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = (item: ContractItem) => {
|
||||||
tabStore.openTab({
|
tabStore.openTab({
|
||||||
id: `detail-${item.id}`,
|
id: `contract-${item.id}`,
|
||||||
title: `合同详情: ${item.name}`,
|
title: `合同段${item.name}`,
|
||||||
componentName: 'ContractDetailView', // 确保在 tab.vue 的 componentMap 里注册了
|
componentName: 'ContractDetailView',
|
||||||
props: { contractId: item.id, contractName: item.name }
|
props: { contractId: item.id, contractName: item.name }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onDragMove = (event: MouseEvent) => {
|
||||||
|
modalOffset.value = {
|
||||||
|
x: baseOffsetX + (event.clientX - dragStartX),
|
||||||
|
y: baseOffsetY + (event.clientY - dragStartY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
window.removeEventListener('mousemove', onDragMove)
|
||||||
|
window.removeEventListener('mouseup', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrag = (event: MouseEvent) => {
|
||||||
|
dragStartX = event.clientX
|
||||||
|
dragStartY = event.clientY
|
||||||
|
baseOffsetX = modalOffset.value.x
|
||||||
|
baseOffsetY = modalOffset.value.y
|
||||||
|
window.addEventListener('mousemove', onDragMove)
|
||||||
|
window.addEventListener('mouseup', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadContracts()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopDrag()
|
||||||
|
if (toastTimer) clearTimeout(toastTimer)
|
||||||
|
void saveContracts()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-bold">合同段列表</h3>
|
<h3 class="text-lg font-bold">合同段列表</h3>
|
||||||
<Button @click="addContract"><Plus class="mr-2 h-4 w-4" /> 添加合同段</Button>
|
<Button @click="openCreateModal">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
添加合同段
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<draggable
|
<draggable
|
||||||
v-model="contracts"
|
v-model="contracts"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||||
animation="200"
|
animation="200"
|
||||||
|
@end="handleDragEnd"
|
||||||
>
|
>
|
||||||
<template #item="{ element }">
|
<template #item="{ element }">
|
||||||
<Card class="group cursor-move hover:border-primary transition-colors">
|
<Card
|
||||||
|
class="group cursor-pointer transition-colors hover:border-primary"
|
||||||
|
@click="handleCardClick(element)"
|
||||||
|
>
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle class="text-sm font-medium">
|
<CardTitle class="text-sm font-medium">
|
||||||
<Input v-model="element.name" class="h-7 border-none focus-visible:ring-1 px-1" />
|
<span class="ml-2">{{ element.name }}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="goToDetail(element)">
|
<Button
|
||||||
<ExternalLink class="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
@click.stop="openEditModal(element)"
|
||||||
|
>
|
||||||
|
<Edit3 class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="deleteContract(element.id)">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 text-destructive"
|
||||||
|
@click.stop="deleteContract(element.id)"
|
||||||
|
>
|
||||||
<Trash2 class="h-4 w-4" />
|
<Trash2 class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<div class="px-6 pb-4 text-xs text-muted-foreground">
|
||||||
|
创建时间:{{ formatDateTime(element.createdAt) }}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showToast"
|
||||||
|
class="fixed left-1/2 top-6 z-[60] -translate-x-1/2 rounded-full border border-sky-100 bg-white/95 px-4 py-2 text-sm text-slate-700 shadow-md backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{{ toastText }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showCreateModal"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-md rounded-lg border bg-background shadow-xl"
|
||||||
|
:style="{ transform: `translate(${modalOffset.x}px, ${modalOffset.y}px)` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b px-5 py-4 cursor-move select-none"
|
||||||
|
@mousedown.prevent="startDrag"
|
||||||
|
>
|
||||||
|
<h4 class="text-base font-semibold">
|
||||||
|
{{ editingContractId ? '编辑合同段' : '新增合同段' }}
|
||||||
|
</h4>
|
||||||
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 px-5 py-4">
|
||||||
|
<label class="block text-sm font-medium text-foreground">合同段名称</label>
|
||||||
|
<input
|
||||||
|
v-model="contractNameInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入合同段名称"
|
||||||
|
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"
|
||||||
|
@keydown.enter="createContract"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
|
||||||
|
<Button variant="outline" @click="closeCreateModal">取消</Button>
|
||||||
|
<Button :disabled="!contractNameInput.trim()" @click="createContract">
|
||||||
|
{{ editingContractId ? '保存' : '确定' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -16,7 +16,7 @@ const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmI
|
|||||||
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
||||||
|
|
||||||
const xmCategories = [
|
const xmCategories = [
|
||||||
{ key: 'info', label: '分类信息', component: xmView },
|
{ key: 'info', label: '基础信息', component: xmView },
|
||||||
{ key: 'contract', label: '合同段管理', component: htView }
|
{ key: 'contract', label: '合同段管理', component: htView }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
428
src/components/views/htInfo.vue
Normal file
428
src/components/views/htInfo.vue
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
|
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||||
|
import localforage from 'localforage'
|
||||||
|
import 'ag-grid-enterprise'
|
||||||
|
import {
|
||||||
|
|
||||||
|
themeQuartz
|
||||||
|
} from "ag-grid-community"
|
||||||
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
|
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||||
|
const borderConfig = {
|
||||||
|
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
|
||||||
|
width: 0.5, // 更细的边框,减少视觉干扰
|
||||||
|
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简洁清新风格的主题配置
|
||||||
|
const myTheme = themeQuartz.withParams({
|
||||||
|
// 核心:移除外边框,减少视觉包裹感
|
||||||
|
wrapperBorder: false,
|
||||||
|
|
||||||
|
// 表头样式(柔和浅蓝,无加粗,更轻盈)
|
||||||
|
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
|
||||||
|
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
|
||||||
|
headerFontSize: 15, // 字体稍大一点,更易读
|
||||||
|
headerFontWeight: "normal", // 取消加粗,降低视觉重量
|
||||||
|
|
||||||
|
// 行/列/表头边框(统一浅灰细边框)
|
||||||
|
rowBorder: borderConfig,
|
||||||
|
columnBorder: borderConfig,
|
||||||
|
headerRowBorder: borderConfig,
|
||||||
|
|
||||||
|
|
||||||
|
// 可选:偶数行背景色(轻微区分,更清新)
|
||||||
|
dataBackgroundColor: "#fefefe"
|
||||||
|
});
|
||||||
|
interface DictLeaf {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictGroup {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
children: DictLeaf[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
id: string
|
||||||
|
groupCode: string
|
||||||
|
groupName: string
|
||||||
|
majorCode: string
|
||||||
|
majorName: string
|
||||||
|
amount: number | null
|
||||||
|
landArea: number | null
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XmInfoState {
|
||||||
|
projectName: string
|
||||||
|
detailRows: DetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string
|
||||||
|
}>()
|
||||||
|
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||||
|
|
||||||
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
|
||||||
|
const detailDict: DictGroup[] = [
|
||||||
|
{
|
||||||
|
code: 'E1',
|
||||||
|
name: '交通运输工程通用专业',
|
||||||
|
children: [
|
||||||
|
{ code: 'E1-1', name: '征地(用海)补偿' },
|
||||||
|
{ code: 'E1-2', name: '拆迁补偿' },
|
||||||
|
{ code: 'E1-3', name: '迁改工程' },
|
||||||
|
{ code: 'E1-4', name: '工程建设其他费' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'E2',
|
||||||
|
name: '公路工程专业',
|
||||||
|
children: [
|
||||||
|
{ code: 'E2-1', name: '临时工程' },
|
||||||
|
{ code: 'E2-2', name: '路基工程' },
|
||||||
|
{ code: 'E2-3', name: '路面工程' },
|
||||||
|
{ code: 'E2-4', name: '桥涵工程' },
|
||||||
|
{ code: 'E2-5', name: '隧道工程' },
|
||||||
|
{ code: 'E2-6', name: '交叉工程' },
|
||||||
|
{ code: 'E2-7', name: '机电工程' },
|
||||||
|
{ code: 'E2-8', name: '交通安全设施工程' },
|
||||||
|
{ code: 'E2-9', name: '绿化及环境保护工程' },
|
||||||
|
{ code: 'E2-10', name: '房建工程' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'E3',
|
||||||
|
name: '铁路工程专业',
|
||||||
|
children: [
|
||||||
|
{ code: 'E3-1', name: '大型临时设施和过渡工程' },
|
||||||
|
{ code: 'E3-2', name: '路基工程' },
|
||||||
|
{ code: 'E3-3', name: '桥涵工程' },
|
||||||
|
{ code: 'E3-4', name: '隧道及明洞工程' },
|
||||||
|
{ code: 'E3-5', name: '轨道工程' },
|
||||||
|
{ code: 'E3-6', name: '通信、信号、信息及灾害监测工程' },
|
||||||
|
{ code: 'E3-7', name: '电力及电力牵引供电工程' },
|
||||||
|
{ code: 'E3-8', name: '房建工程(房屋建筑及附属工程)' },
|
||||||
|
{ code: 'E3-9', name: '装饰装修工程' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'E4',
|
||||||
|
name: '水运工程专业',
|
||||||
|
children: [
|
||||||
|
{ code: 'E4-1', name: '临时工程' },
|
||||||
|
{ code: 'E4-2', name: '土建工程' },
|
||||||
|
{ code: 'E4-3', name: '机电与金属结构工程' },
|
||||||
|
{ code: 'E4-4', name: '设备工程' },
|
||||||
|
{ code: 'E4-5', name: '附属房建工程(房屋建筑及附属工程)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const codeNameMap = new Map<string, string>()
|
||||||
|
for (const group of detailDict) {
|
||||||
|
codeNameMap.set(group.code, group.name)
|
||||||
|
for (const child of group.children) {
|
||||||
|
codeNameMap.set(child.code, child.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDefaultRows = (): DetailRow[] => {
|
||||||
|
const rows: DetailRow[] = []
|
||||||
|
for (const group of detailDict) {
|
||||||
|
for (const child of group.children) {
|
||||||
|
rows.push({
|
||||||
|
id: `row-${child.code}`,
|
||||||
|
groupCode: group.code,
|
||||||
|
groupName: group.name,
|
||||||
|
majorCode: child.code,
|
||||||
|
majorName: child.name,
|
||||||
|
amount: null,
|
||||||
|
landArea: null,
|
||||||
|
path: [group.code, child.code]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(row.majorCode, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.majorCode)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||||
|
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnDefs: ColDef<DetailRow>[] = [
|
||||||
|
|
||||||
|
{
|
||||||
|
headerName: '造价金额(万元)',
|
||||||
|
field: 'amount',
|
||||||
|
minWidth: 170,
|
||||||
|
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||||
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||||
|
cellClassRules: {
|
||||||
|
'editable-cell-empty': params =>
|
||||||
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
|
},
|
||||||
|
aggFunc: 'sum',
|
||||||
|
valueParser: params => {
|
||||||
|
if (params.newValue === '' || params.newValue == null) return null
|
||||||
|
const v = Number(params.newValue)
|
||||||
|
return Number.isFinite(v) ? v : null
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return Number(params.value).toFixed(2)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: '用地面积(亩)',
|
||||||
|
field: 'landArea',
|
||||||
|
minWidth: 170,
|
||||||
|
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||||
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||||
|
cellClassRules: {
|
||||||
|
'editable-cell-empty': params =>
|
||||||
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
|
},
|
||||||
|
aggFunc: 'sum',
|
||||||
|
valueParser: params => {
|
||||||
|
if (params.newValue === '' || params.newValue == null) return null
|
||||||
|
const v = Number(params.newValue)
|
||||||
|
return Number.isFinite(v) ? v : null
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return Number(params.value).toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const autoGroupColumnDef: ColDef = {
|
||||||
|
headerName: '专业编码以及工程专业名称',
|
||||||
|
minWidth: 320,
|
||||||
|
pinned: 'left',
|
||||||
|
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
|
cellRendererParams: {
|
||||||
|
suppressCount: true
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (params.node?.rowPinned) {
|
||||||
|
return '总合计'
|
||||||
|
}
|
||||||
|
const code = String(params.value || '')
|
||||||
|
const name = codeNameMap.get(code) || ''
|
||||||
|
return name ? `${code} ${name}` : code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridOptions: GridOptions<DetailRow> = {
|
||||||
|
treeData: true,
|
||||||
|
animateRows: true,
|
||||||
|
singleClickEdit: true,
|
||||||
|
suppressClickEdit: false,
|
||||||
|
suppressContextMenu: false,
|
||||||
|
groupDefaultExpanded: -1,
|
||||||
|
suppressFieldDotNotation: true,
|
||||||
|
getDataPath: data => data.path,
|
||||||
|
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||||
|
defaultColDef: {
|
||||||
|
resizable: true,
|
||||||
|
sortable: false,
|
||||||
|
filter: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = computed(() =>
|
||||||
|
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalLandArea = computed(() =>
|
||||||
|
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
|
||||||
|
)
|
||||||
|
const pinnedTopRowData = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'pinned-total-row',
|
||||||
|
groupCode: '',
|
||||||
|
groupName: '',
|
||||||
|
majorCode: '',
|
||||||
|
majorName: '',
|
||||||
|
amount: totalAmount.value,
|
||||||
|
landArea: totalLandArea.value,
|
||||||
|
path: ['TOTAL']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const saveToIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
|
}
|
||||||
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveToIndexedDB failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||||
|
if (data) {
|
||||||
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const schedulePersist = () => {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
// const handleBeforeUnload = () => {
|
||||||
|
// void saveToIndexedDB()
|
||||||
|
// }
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleCellValueChanged = () => {
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFromIndexedDB()
|
||||||
|
// window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
void saveToIndexedDB()
|
||||||
|
})
|
||||||
|
const processCellForClipboard = (params:any) => {
|
||||||
|
if (Array.isArray(params.value)) {
|
||||||
|
return JSON.stringify(params.value); // 数组转字符串复制
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCellFromClipboard = (params:any) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(params.value);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时返回原始值,无需额外处理
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-card xmMx">
|
||||||
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">合同规模明细</h3>
|
||||||
|
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ag-theme-quartz h-[580px] w-full">
|
||||||
|
<AgGridVue
|
||||||
|
:style="{ height: '100%' }"
|
||||||
|
:rowData="detailRows"
|
||||||
|
:pinnedTopRowData="pinnedTopRowData"
|
||||||
|
:columnDefs="columnDefs"
|
||||||
|
:autoGroupColumnDef="autoGroupColumnDef"
|
||||||
|
:gridOptions="gridOptions"
|
||||||
|
:theme="myTheme"
|
||||||
|
@cell-value-changed="handleCellValueChanged"
|
||||||
|
:suppressColumnVirtualisation="true"
|
||||||
|
:suppressRowVirtualisation="true"
|
||||||
|
:cellSelection="{ handle: { mode: 'range' } }"
|
||||||
|
:enableClipboard="true"
|
||||||
|
:localeText="AG_GRID_LOCALE_CN"
|
||||||
|
:tooltipShowDelay="500"
|
||||||
|
:headerHeight="50"
|
||||||
|
:processCellForClipboard="processCellForClipboard"
|
||||||
|
:processCellFromClipboard="processCellFromClipboard"
|
||||||
|
:undoRedoCellEditing="true"
|
||||||
|
:undoRedoCellEditingLimit="20"
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style >
|
||||||
|
.ag-floating-top{
|
||||||
|
overflow-y:auto !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .editable-cell-line .ag-cell-value {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 84%;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-bottom: 1px solid #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||||
|
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||||
|
border-bottom-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .editable-cell-empty .ag-cell-value {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .ag-cell.editable-cell-empty,
|
||||||
|
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||||
|
import localforage from 'localforage'
|
||||||
import 'ag-grid-enterprise'
|
import 'ag-grid-enterprise'
|
||||||
import {
|
import {
|
||||||
|
|
||||||
@ -62,8 +63,6 @@ interface XmInfoState {
|
|||||||
detailRows: DetailRow[]
|
detailRows: DetailRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_NAME = 'jgjs-pricing-db'
|
|
||||||
const DB_STORE = 'form-state'
|
|
||||||
const DB_KEY = 'xm-info-v3'
|
const DB_KEY = 'xm-info-v3'
|
||||||
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||||
|
|
||||||
@ -282,41 +281,15 @@ const pinnedTopRowData = computed(() => [
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const openDB = () =>
|
|
||||||
new Promise<IDBDatabase>((resolve, reject) => {
|
|
||||||
const request = window.indexedDB.open(DB_NAME, 1)
|
|
||||||
|
|
||||||
request.onupgradeneeded = () => {
|
|
||||||
const db = request.result
|
|
||||||
if (!db.objectStoreNames.contains(DB_STORE)) {
|
|
||||||
db.createObjectStore(DB_STORE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result)
|
|
||||||
request.onerror = () => reject(request.error)
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveToIndexedDB = async () => {
|
const saveToIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const db = await openDB()
|
|
||||||
const tx = db.transaction(DB_STORE, 'readwrite')
|
|
||||||
const store = tx.objectStore(DB_STORE)
|
|
||||||
|
|
||||||
const payload: XmInfoState = {
|
const payload: XmInfoState = {
|
||||||
projectName: projectName.value,
|
projectName: projectName.value,
|
||||||
detailRows: detailRows.value
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
}
|
}
|
||||||
|
await localforage.setItem(DB_KEY, payload)
|
||||||
store.put(payload, DB_KEY)
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
tx.oncomplete = () => resolve()
|
|
||||||
tx.onerror = () => reject(tx.error)
|
|
||||||
tx.onabort = () => reject(tx.error)
|
|
||||||
})
|
|
||||||
|
|
||||||
db.close()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
}
|
}
|
||||||
@ -324,24 +297,8 @@ const saveToIndexedDB = async () => {
|
|||||||
|
|
||||||
const loadFromIndexedDB = async () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
const db = await openDB()
|
const data = await localforage.getItem<XmInfoState>(DB_KEY)
|
||||||
const tx = db.transaction(DB_STORE, 'readonly')
|
console.log(data)
|
||||||
const store = tx.objectStore(DB_STORE)
|
|
||||||
const request = store.get(DB_KEY)
|
|
||||||
|
|
||||||
const data = await new Promise<XmInfoState | undefined>((resolve, reject) => {
|
|
||||||
request.onsuccess = () => resolve(request.result as XmInfoState | undefined)
|
|
||||||
request.onerror = () => reject(request.error)
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
tx.oncomplete = () => resolve()
|
|
||||||
tx.onerror = () => reject(tx.error)
|
|
||||||
tx.onabort = () => reject(tx.error)
|
|
||||||
})
|
|
||||||
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
||||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
@ -363,24 +320,29 @@ const schedulePersist = () => {
|
|||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBeforeUnload = () => {
|
// const handleBeforeUnload = () => {
|
||||||
void saveToIndexedDB()
|
// void saveToIndexedDB()
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const handleCellValueChanged = () => {
|
const handleCellValueChanged = () => {
|
||||||
schedulePersist()
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(projectName, schedulePersist)
|
watch(projectName, schedulePersist)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadFromIndexedDB()
|
await loadFromIndexedDB()
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
// window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
if (persistTimer) clearTimeout(persistTimer)
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
void saveToIndexedDB()
|
void saveToIndexedDB()
|
||||||
})
|
})
|
||||||
const processCellForClipboard = (params:any) => {
|
const processCellForClipboard = (params:any) => {
|
||||||
|
|||||||
377
src/components/views/zxFw.vue
Normal file
377
src/components/views/zxFw.vue
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
|
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||||
|
import localforage from 'localforage'
|
||||||
|
import 'ag-grid-enterprise'
|
||||||
|
import {
|
||||||
|
|
||||||
|
themeQuartz
|
||||||
|
} from "ag-grid-community"
|
||||||
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
|
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||||
|
const borderConfig = {
|
||||||
|
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
|
||||||
|
width: 0.5, // 更细的边框,减少视觉干扰
|
||||||
|
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简洁清新风格的主题配置
|
||||||
|
const myTheme = themeQuartz.withParams({
|
||||||
|
// 核心:移除外边框,减少视觉包裹感
|
||||||
|
wrapperBorder: false,
|
||||||
|
|
||||||
|
// 表头样式(柔和浅蓝,无加粗,更轻盈)
|
||||||
|
headerBackgroundColor: "#f9fafb", // 极浅的背景色,替代深一点的 #e7f3fc
|
||||||
|
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
|
||||||
|
headerFontSize: 15, // 字体稍大一点,更易读
|
||||||
|
headerFontWeight: "normal", // 取消加粗,降低视觉重量
|
||||||
|
|
||||||
|
// 行/列/表头边框(统一浅灰细边框)
|
||||||
|
rowBorder: borderConfig,
|
||||||
|
columnBorder: borderConfig,
|
||||||
|
headerRowBorder: borderConfig,
|
||||||
|
|
||||||
|
|
||||||
|
// 可选:偶数行背景色(轻微区分,更清新)
|
||||||
|
dataBackgroundColor: "#fefefe"
|
||||||
|
});
|
||||||
|
interface DictLeaf {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictGroup {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
children: DictLeaf[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
id: string
|
||||||
|
groupCode: string
|
||||||
|
groupName: string
|
||||||
|
majorCode: string
|
||||||
|
majorName: string
|
||||||
|
amount: number | null
|
||||||
|
landArea: number | null
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XmInfoState {
|
||||||
|
detailRows: DetailRow[]
|
||||||
|
}
|
||||||
|
const props = defineProps<{
|
||||||
|
contractId: string
|
||||||
|
}>()
|
||||||
|
const DB_KEY = computed(() => `zxFW-${props.contractId}`)
|
||||||
|
|
||||||
|
|
||||||
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
|
||||||
|
const detailDict: DictGroup[] =[]
|
||||||
|
|
||||||
|
const codeNameMap = new Map<string, string>()
|
||||||
|
for (const group of detailDict) {
|
||||||
|
codeNameMap.set(group.code, group.name)
|
||||||
|
for (const child of group.children) {
|
||||||
|
codeNameMap.set(child.code, child.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDefaultRows = (): DetailRow[] => {
|
||||||
|
const rows: DetailRow[] = []
|
||||||
|
for (const group of detailDict) {
|
||||||
|
for (const child of group.children) {
|
||||||
|
rows.push({
|
||||||
|
id: `row-${child.code}`,
|
||||||
|
groupCode: group.code,
|
||||||
|
groupName: group.name,
|
||||||
|
majorCode: child.code,
|
||||||
|
majorName: child.name,
|
||||||
|
amount: null,
|
||||||
|
landArea: null,
|
||||||
|
path: [group.code, child.code]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||||
|
const dbValueMap = new Map<string, DetailRow>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(row.majorCode, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.majorCode)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||||
|
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnDefs: ColDef<DetailRow>[] = [
|
||||||
|
|
||||||
|
{
|
||||||
|
headerName: '造价金额(万元)',
|
||||||
|
field: 'amount',
|
||||||
|
minWidth: 170,
|
||||||
|
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||||
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||||
|
cellClassRules: {
|
||||||
|
'editable-cell-empty': params =>
|
||||||
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
|
},
|
||||||
|
aggFunc: 'sum',
|
||||||
|
valueParser: params => {
|
||||||
|
if (params.newValue === '' || params.newValue == null) return null
|
||||||
|
const v = Number(params.newValue)
|
||||||
|
return Number.isFinite(v) ? v : null
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return Number(params.value).toFixed(2)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: '用地面积(亩)',
|
||||||
|
field: 'landArea',
|
||||||
|
minWidth: 170,
|
||||||
|
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||||
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||||
|
cellClassRules: {
|
||||||
|
'editable-cell-empty': params =>
|
||||||
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
|
},
|
||||||
|
aggFunc: 'sum',
|
||||||
|
valueParser: params => {
|
||||||
|
if (params.newValue === '' || params.newValue == null) return null
|
||||||
|
const v = Number(params.newValue)
|
||||||
|
return Number.isFinite(v) ? v : null
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return Number(params.value).toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const autoGroupColumnDef: ColDef = {
|
||||||
|
headerName: '专业编码以及工程专业名称',
|
||||||
|
minWidth: 320,
|
||||||
|
pinned: 'left',
|
||||||
|
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
|
cellRendererParams: {
|
||||||
|
suppressCount: true
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (params.node?.rowPinned) {
|
||||||
|
return '总合计'
|
||||||
|
}
|
||||||
|
const code = String(params.value || '')
|
||||||
|
const name = codeNameMap.get(code) || ''
|
||||||
|
return name ? `${code} ${name}` : code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridOptions: GridOptions<DetailRow> = {
|
||||||
|
treeData: true,
|
||||||
|
animateRows: true,
|
||||||
|
singleClickEdit: true,
|
||||||
|
suppressClickEdit: false,
|
||||||
|
suppressContextMenu: false,
|
||||||
|
groupDefaultExpanded: -1,
|
||||||
|
suppressFieldDotNotation: true,
|
||||||
|
getDataPath: data => data.path,
|
||||||
|
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||||
|
defaultColDef: {
|
||||||
|
resizable: true,
|
||||||
|
sortable: false,
|
||||||
|
filter: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = computed(() =>
|
||||||
|
detailRows.value.reduce((sum, row) => sum + (row.amount || 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalLandArea = computed(() =>
|
||||||
|
detailRows.value.reduce((sum, row) => sum + (row.landArea || 0), 0)
|
||||||
|
)
|
||||||
|
const pinnedTopRowData = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'pinned-total-row',
|
||||||
|
groupCode: '',
|
||||||
|
groupName: '',
|
||||||
|
majorCode: '',
|
||||||
|
majorName: '',
|
||||||
|
amount: totalAmount.value,
|
||||||
|
landArea: totalLandArea.value,
|
||||||
|
path: ['TOTAL']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const saveToIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const payload: XmInfoState = {
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
|
}
|
||||||
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveToIndexedDB failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||||||
|
if (data) {
|
||||||
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const schedulePersist = () => {
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
// const handleBeforeUnload = () => {
|
||||||
|
// void saveToIndexedDB()
|
||||||
|
// }
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleCellValueChanged = () => {
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFromIndexedDB()
|
||||||
|
// window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
void saveToIndexedDB()
|
||||||
|
})
|
||||||
|
const processCellForClipboard = (params:any) => {
|
||||||
|
if (Array.isArray(params.value)) {
|
||||||
|
return JSON.stringify(params.value); // 数组转字符串复制
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCellFromClipboard = (params:any) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(params.value);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败时返回原始值,无需额外处理
|
||||||
|
}
|
||||||
|
return params.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-lg border bg-card p-4 shadow-sm">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-foreground">选择服务</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-card xmMx">
|
||||||
|
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">咨询服务</h3>
|
||||||
|
<div class="text-xs text-muted-foreground">导入导出</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ag-theme-quartz h-[580px] w-full">
|
||||||
|
<AgGridVue
|
||||||
|
:style="{ height: '100%' }"
|
||||||
|
:rowData="detailRows"
|
||||||
|
:pinnedTopRowData="pinnedTopRowData"
|
||||||
|
:columnDefs="columnDefs"
|
||||||
|
:autoGroupColumnDef="autoGroupColumnDef"
|
||||||
|
:gridOptions="gridOptions"
|
||||||
|
:theme="myTheme"
|
||||||
|
@cell-value-changed="handleCellValueChanged"
|
||||||
|
:suppressColumnVirtualisation="true"
|
||||||
|
:suppressRowVirtualisation="true"
|
||||||
|
:cellSelection="{ handle: { mode: 'range' } }"
|
||||||
|
:enableClipboard="true"
|
||||||
|
:localeText="AG_GRID_LOCALE_CN"
|
||||||
|
:tooltipShowDelay="500"
|
||||||
|
:headerHeight="50"
|
||||||
|
:processCellForClipboard="processCellForClipboard"
|
||||||
|
:processCellFromClipboard="processCellFromClipboard"
|
||||||
|
:undoRedoCellEditing="true"
|
||||||
|
:undoRedoCellEditingLimit="20"
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style >
|
||||||
|
.ag-floating-top{
|
||||||
|
overflow-y:auto !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .editable-cell-line .ag-cell-value {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 84%;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-bottom: 1px solid #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||||
|
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||||
|
border-bottom-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .editable-cell-empty .ag-cell-value {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .ag-cell.editable-cell-empty,
|
||||||
|
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,51 +1,340 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, defineAsyncComponent, markRaw, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { defineAsyncComponent, markRaw } from 'vue'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||||
import { X } from 'lucide-vue-next'
|
import { ChevronDown, RotateCcw, X } from 'lucide-vue-next'
|
||||||
// 组件映射表:所有的动态组件需要在这里注册
|
import localforage from 'localforage'
|
||||||
|
import {
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogRoot,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
interface DataEntry {
|
||||||
|
key: string
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataPackage {
|
||||||
|
version: number
|
||||||
|
exportedAt: string
|
||||||
|
localStorage: DataEntry[]
|
||||||
|
sessionStorage: DataEntry[]
|
||||||
|
localforageDefault: DataEntry[]
|
||||||
|
localforageFormState: DataEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
const componentMap: Record<string, any> = {
|
const componentMap: Record<string, any> = {
|
||||||
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
|
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
|
||||||
|
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/ContractDetailView.vue'))),
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
// useTabStore
|
|
||||||
|
const formStore = localforage.createInstance({
|
||||||
|
name: 'jgjs-pricing-db',
|
||||||
|
storeName: 'form_state'
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabContextOpen = ref(false)
|
||||||
|
const tabContextX = ref(0)
|
||||||
|
const tabContextY = ref(0)
|
||||||
|
const contextTabId = ref<string>('XmView')
|
||||||
|
const tabContextRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const dataMenuOpen = ref(false)
|
||||||
|
const dataMenuRef = ref<HTMLElement | null>(null)
|
||||||
|
const importFileRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const tabsModel = computed({
|
||||||
|
get: () => tabStore.tabs,
|
||||||
|
set: (value) => {
|
||||||
|
tabStore.tabs = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const contextTabIndex = computed(() => tabStore.tabs.findIndex(t => t.id === contextTabId.value))
|
||||||
|
|
||||||
|
const hasClosableTabs = computed(() => tabStore.tabs.some(t => t.id !== 'XmView'))
|
||||||
|
const canCloseLeft = computed(() => {
|
||||||
|
if (contextTabIndex.value <= 0) return false
|
||||||
|
return tabStore.tabs.slice(0, contextTabIndex.value).some(t => t.id !== 'XmView')
|
||||||
|
})
|
||||||
|
const canCloseRight = computed(() => {
|
||||||
|
if (contextTabIndex.value < 0) return false
|
||||||
|
return tabStore.tabs.slice(contextTabIndex.value + 1).some(t => t.id !== 'XmView')
|
||||||
|
})
|
||||||
|
const canCloseOther = computed(() =>
|
||||||
|
tabStore.tabs.some(t => t.id !== 'XmView' && t.id !== contextTabId.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const closeMenus = () => {
|
||||||
|
tabContextOpen.value = false
|
||||||
|
dataMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTabContextMenu = (event: MouseEvent, tabId: string) => {
|
||||||
|
contextTabId.value = tabId
|
||||||
|
tabContextX.value = event.clientX
|
||||||
|
tabContextY.value = event.clientY
|
||||||
|
tabContextOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalMouseDown = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (tabContextOpen.value && tabContextRef.value && !tabContextRef.value.contains(target)) {
|
||||||
|
tabContextOpen.value = false
|
||||||
|
}
|
||||||
|
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
|
||||||
|
dataMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTabMenuAction = (action: 'all' | 'left' | 'right' | 'other') => {
|
||||||
|
if (action === 'all') tabStore.closeAllTabs()
|
||||||
|
if (action === 'left') tabStore.closeLeftTabs(contextTabId.value)
|
||||||
|
if (action === 'right') tabStore.closeRightTabs(contextTabId.value)
|
||||||
|
if (action === 'other') tabStore.closeOtherTabs(contextTabId.value)
|
||||||
|
tabContextOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const canMoveTab = (event: any) => {
|
||||||
|
const draggedId = event?.draggedContext?.element?.id
|
||||||
|
const targetIndex = event?.relatedContext?.index
|
||||||
|
if (draggedId === 'XmView') return false
|
||||||
|
if (typeof targetIndex === 'number' && targetIndex === 0) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const readWebStorage = (storageObj: Storage): DataEntry[] => {
|
||||||
|
const entries: DataEntry[] = []
|
||||||
|
for (let i = 0; i < storageObj.length; i++) {
|
||||||
|
const key = storageObj.key(i)
|
||||||
|
if (!key) continue
|
||||||
|
const raw = storageObj.getItem(key)
|
||||||
|
let value: any = raw
|
||||||
|
if (raw != null) {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
value = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push({ key, value })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
|
||||||
|
storageObj.clear()
|
||||||
|
for (const entry of entries || []) {
|
||||||
|
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
|
||||||
|
storageObj.setItem(entry.key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readForage = async (store: typeof localforage): Promise<DataEntry[]> => {
|
||||||
|
const keys = await store.keys()
|
||||||
|
const entries: DataEntry[] = []
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = await store.getItem(key)
|
||||||
|
entries.push({ key, value })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeForage = async (store: typeof localforage, entries: DataEntry[]) => {
|
||||||
|
await store.clear()
|
||||||
|
for (const entry of entries || []) {
|
||||||
|
await store.setItem(entry.key, entry.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData = async () => {
|
||||||
|
try {
|
||||||
|
const payload: DataPackage = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
localStorage: readWebStorage(localStorage),
|
||||||
|
sessionStorage: readWebStorage(sessionStorage),
|
||||||
|
localforageDefault: await readForage(localforage),
|
||||||
|
localforageFormState: await readForage(formStore as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = JSON.stringify(payload, null, 2)
|
||||||
|
const blob = new Blob([content], { type: 'application/json;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `jgjs-data-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('export failed:', error)
|
||||||
|
window.alert('导出失败,请重试。')
|
||||||
|
} finally {
|
||||||
|
dataMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerImport = () => {
|
||||||
|
importFileRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const importData = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const payload = JSON.parse(text) as DataPackage
|
||||||
|
|
||||||
|
writeWebStorage(localStorage, payload.localStorage || [])
|
||||||
|
writeWebStorage(sessionStorage, payload.sessionStorage || [])
|
||||||
|
await writeForage(localforage, payload.localforageDefault || [])
|
||||||
|
await writeForage(formStore as any, payload.localforageFormState || [])
|
||||||
|
|
||||||
|
tabStore.resetTabs()
|
||||||
|
dataMenuOpen.value = false
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('import failed:', error)
|
||||||
|
window.alert('导入失败,文件格式不正确。')
|
||||||
|
} finally {
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
await localforage.clear()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('reset failed:', error)
|
||||||
|
} finally {
|
||||||
|
tabStore.resetTabs()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mousedown', handleGlobalMouseDown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||||||
<div class="flex items-center border-b bg-muted/30 px-2 pt-2 flex-none">
|
<div class="flex items-center border-b bg-muted/30 px-2 pt-2 flex-none">
|
||||||
<ScrollArea class="flex-1 whitespace-nowrap">
|
<ScrollArea class="flex-1 whitespace-nowrap">
|
||||||
<div class="flex gap-1">
|
<draggable
|
||||||
|
v-model="tabsModel"
|
||||||
|
item-key="id"
|
||||||
|
tag="div"
|
||||||
|
class="flex gap-1"
|
||||||
|
:animation="180"
|
||||||
|
:move="canMoveTab"
|
||||||
|
>
|
||||||
|
<template #item="{ element: tab }">
|
||||||
<div
|
<div
|
||||||
v-for="(tab, index) in tabStore.tabs"
|
|
||||||
:key="tab.id"
|
|
||||||
@click="tabStore.activeTabId = tab.id"
|
@click="tabStore.activeTabId = tab.id"
|
||||||
|
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
|
||||||
:class="[
|
:class="[
|
||||||
'group relative flex items-center h-9 px-4 min-w-[120px] max-w-[200px] cursor-pointer rounded-t-md border-x border-t transition-all text-sm',
|
'group relative flex items-center h-9 px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border-x border-t transition-all text-sm',
|
||||||
tabStore.activeTabId === tab.id
|
tabStore.activeTabId === tab.id
|
||||||
? 'bg-background border-border font-medium'
|
? 'bg-background border-border font-medium'
|
||||||
: 'border-transparent hover:bg-muted text-muted-foreground'
|
: 'border-transparent hover:bg-muted text-muted-foreground',
|
||||||
|
tab.id !== 'XmView' ? 'cursor-move' : ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span class="truncate mr-2">{{ tab.title }}</span>
|
<span class="truncate mr-2">{{ tab.title }}</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
v-if="index !== 0"
|
v-if="tab.id !== 'XmView'"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
||||||
@click="tabStore.removeTab(tab.id)"
|
@click.stop="tabStore.removeTab(tab.id)"
|
||||||
>
|
>
|
||||||
<X class="h-3 w-3" />
|
<X class="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</draggable>
|
||||||
<ScrollBar orientation="horizontal" class="invisible" />
|
<ScrollBar orientation="horizontal" class="invisible" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div ref="dataMenuRef" class="relative ml-2 mb-2">
|
||||||
|
<Button variant="outline" size="sm" @click="dataMenuOpen = !dataMenuOpen">
|
||||||
|
<ChevronDown class="h-4 w-4 mr-1" />
|
||||||
|
导入/导出
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="dataMenuOpen"
|
||||||
|
class="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-md border bg-background p-1 shadow-md"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
|
@click="exportData"
|
||||||
|
>
|
||||||
|
导出数据
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
|
@click="triggerImport"
|
||||||
|
>
|
||||||
|
导入数据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="importFileRef"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
@change="importData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogRoot>
|
||||||
|
<AlertDialogTrigger as-child>
|
||||||
|
<Button variant="destructive" size="sm" class="ml-2 mb-2">
|
||||||
|
<RotateCcw class="h-4 w-4 mr-1" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||||
|
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||||
|
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||||
|
将清空本地缓存(IndexDB / LocalStorage / SessionStorage)并恢复默认页面,确认继续吗?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<div class="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<AlertDialogCancel as-child>
|
||||||
|
<Button variant="outline">取消</Button>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction as-child>
|
||||||
|
<Button variant="destructive" @click="handleReset">确认重置</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto relative">
|
<div class="flex-1 overflow-auto relative">
|
||||||
@ -55,8 +344,44 @@ const tabStore = useTabStore()
|
|||||||
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"
|
||||||
>
|
>
|
||||||
<component :is="componentMap[tab.componentName]" />
|
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="tabContextOpen"
|
||||||
|
ref="tabContextRef"
|
||||||
|
class="fixed z-[70] min-w-[150px] rounded-md border bg-background p-1 shadow-lg"
|
||||||
|
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!hasClosableTabs"
|
||||||
|
@click="runTabMenuAction('all')"
|
||||||
|
>
|
||||||
|
删除所有
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!canCloseLeft"
|
||||||
|
@click="runTabMenuAction('left')"
|
||||||
|
>
|
||||||
|
删除左侧
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!canCloseRight"
|
||||||
|
@click="runTabMenuAction('right')"
|
||||||
|
>
|
||||||
|
删除右侧
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!canCloseOther"
|
||||||
|
@click="runTabMenuAction('other')"
|
||||||
|
>
|
||||||
|
删除其他
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -57,7 +57,7 @@ const activeComponent = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full w-full bg-background">
|
<div class="flex h-full w-full bg-background">
|
||||||
<div class="w-1/5 border-r p-6 flex flex-col gap-8 relative">
|
<div class="w-1/5 border-r p-6 flex flex-col gap-8 relative">
|
||||||
<div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div>
|
<!-- <div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</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>
|
||||||
|
|||||||
@ -3,15 +3,32 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
export const useTabStore = defineStore('tabs', () => {
|
export const useTabStore = defineStore('tabs', () => {
|
||||||
const tabs = ref([
|
interface TabItem<T = Record<string, any>> {
|
||||||
|
id: string; // 标签唯一标识
|
||||||
|
title: string; // 标签标题
|
||||||
|
componentName: string; // 组件名称
|
||||||
|
props?: T; // 传递给组件的 props(可选,泛型适配不同组件)
|
||||||
|
}
|
||||||
|
const defaultTabs :TabItem[]= [
|
||||||
{ id: 'XmView', title: '项目卡片', componentName: 'XmView' }
|
{ id: 'XmView', title: '项目卡片', componentName: 'XmView' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabs = ref([
|
||||||
|
...defaultTabs
|
||||||
])
|
])
|
||||||
const activeTabId = ref('XmView')
|
const activeTabId = ref('XmView')
|
||||||
|
|
||||||
|
const ensureActiveValid = () => {
|
||||||
|
const activeExists = tabs.value.some(t => t.id === activeTabId.value)
|
||||||
|
if (!activeExists) {
|
||||||
|
activeTabId.value = tabs.value[0]?.id || 'XmView'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openTab = (config: { id: string; title: string; componentName: string; props?: any }) => {
|
const openTab = (config: { id: string; title: string; componentName: string; props?: any }) => {
|
||||||
const exists = tabs.value.find(t => t.id === config.id)
|
const exists = tabs.value.some(t => t.id === config.id)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
tabs.value.push(config)
|
tabs.value = [...tabs.value, config]
|
||||||
}
|
}
|
||||||
activeTabId.value = config.id
|
activeTabId.value = config.id
|
||||||
}
|
}
|
||||||
@ -19,13 +36,72 @@ export const useTabStore = defineStore('tabs', () => {
|
|||||||
const removeTab = (id: string) => {
|
const removeTab = (id: string) => {
|
||||||
if (id === 'XmView') return // 首页不可删除
|
if (id === 'XmView') return // 首页不可删除
|
||||||
const index = tabs.value.findIndex(t => t.id === id)
|
const index = tabs.value.findIndex(t => t.id === id)
|
||||||
if (activeTabId.value === id) {
|
if (index < 0) return
|
||||||
activeTabId.value = (tabs.value[index - 1]?.id || tabs.value[index + 1]?.id ) as string
|
const wasActive = activeTabId.value === id
|
||||||
}
|
tabs.value = tabs.value.filter(t => t.id !== id)
|
||||||
tabs.value.splice(index, 1)
|
|
||||||
|
if (tabs.value.length === 0) {
|
||||||
|
tabs.value = [...defaultTabs]
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tabs, activeTabId, openTab, removeTab }
|
if (wasActive) {
|
||||||
|
const fallbackIndex = Math.max(0, Math.min(index - 1, tabs.value.length - 1))
|
||||||
|
activeTabId.value = tabs.value[fallbackIndex]?.id || 'XmView'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStillExists = tabs.value.some(t => t.id === activeTabId.value)
|
||||||
|
if (!activeStillExists) {
|
||||||
|
activeTabId.value = tabs.value[0]?.id || 'XmView'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAllTabs = () => {
|
||||||
|
tabs.value = tabs.value.filter(t => t.id === 'XmView')
|
||||||
|
if (tabs.value.length === 0) tabs.value = [...defaultTabs]
|
||||||
|
activeTabId.value = 'XmView'
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeLeftTabs = (targetId: string) => {
|
||||||
|
const targetIndex = tabs.value.findIndex(t => t.id === targetId)
|
||||||
|
if (targetIndex < 0) return
|
||||||
|
tabs.value = tabs.value.filter((tab, index) => tab.id === 'XmView' || index >= targetIndex)
|
||||||
|
ensureActiveValid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRightTabs = (targetId: string) => {
|
||||||
|
const targetIndex = tabs.value.findIndex(t => t.id === targetId)
|
||||||
|
if (targetIndex < 0) return
|
||||||
|
tabs.value = tabs.value.filter((tab, index) => tab.id === 'XmView' || index <= targetIndex)
|
||||||
|
ensureActiveValid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOtherTabs = (targetId: string) => {
|
||||||
|
tabs.value = tabs.value.filter(tab => tab.id === 'XmView' || tab.id === targetId)
|
||||||
|
if (tabs.value.length === 0) tabs.value = [...defaultTabs]
|
||||||
|
if (targetId === 'XmView') {
|
||||||
|
activeTabId.value = 'XmView'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeTabId.value = tabs.value.some(t => t.id === targetId) ? targetId : 'XmView'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTabs = () => {
|
||||||
|
tabs.value = [...defaultTabs]
|
||||||
|
activeTabId.value = 'XmView'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
openTab,
|
||||||
|
removeTab,
|
||||||
|
closeAllTabs,
|
||||||
|
closeLeftTabs,
|
||||||
|
closeRightTabs,
|
||||||
|
closeOtherTabs,
|
||||||
|
resetTabs
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
// --- 关键配置:开启持久化 ---
|
// --- 关键配置:开启持久化 ---
|
||||||
persist: {
|
persist: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user