fix more
This commit is contained in:
parent
f121aa233e
commit
5734cfa534
@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>my-vue-app</title>
|
||||
<title>造价计算工具</title>
|
||||
</head>
|
||||
<body>
|
||||
<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 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: {
|
||||
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">
|
||||
import { ref } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import localforage from 'localforage'
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 contracts = ref([
|
||||
{ id: 'ct-1', name: '土建一标段' },
|
||||
{ id: 'ct-2', name: '绿化配套标段' }
|
||||
])
|
||||
|
||||
// 添加合同
|
||||
const addContract = () => {
|
||||
const newId = `ct-${Date.now()}`
|
||||
contracts.value.push({ id: newId, name: '新建合同段' })
|
||||
|
||||
const contracts = ref<ContractItem[]>([])
|
||||
|
||||
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 deleteContract = (id: string) => {
|
||||
contracts.value = contracts.value.filter(c => c.id !== id)
|
||||
const notify = (text: string) => {
|
||||
toastText.value = text
|
||||
showToast.value = true
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => {
|
||||
showToast.value = false
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
// 【关键逻辑】点击进入详细页面,打开新 Tab
|
||||
const goToDetail = (item: any) => {
|
||||
const saveContracts = async () => {
|
||||
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({
|
||||
id: `detail-${item.id}`,
|
||||
title: `合同详情: ${item.name}`,
|
||||
componentName: 'ContractDetailView', // 确保在 tab.vue 的 componentMap 里注册了
|
||||
id: `contract-${item.id}`,
|
||||
title: `合同段${item.name}`,
|
||||
componentName: 'ContractDetailView',
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<draggable
|
||||
v-model="contracts"
|
||||
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"
|
||||
@end="handleDragEnd"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="goToDetail(element)">
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click.stop="openEditModal(element)"
|
||||
>
|
||||
<Edit3 class="h-4 w-4" />
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div class="px-6 pb-4 text-xs text-muted-foreground">
|
||||
创建时间:{{ formatDateTime(element.createdAt) }}
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
@ -16,7 +16,7 @@ const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmI
|
||||
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
||||
|
||||
const xmCategories = [
|
||||
{ key: 'info', label: '分类信息', component: xmView },
|
||||
{ key: 'info', label: '基础信息', component: xmView },
|
||||
{ key: 'contract', label: '合同段管理', component: htView }
|
||||
]
|
||||
</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 { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridOptions } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import 'ag-grid-enterprise'
|
||||
import {
|
||||
|
||||
@ -62,8 +63,6 @@ interface XmInfoState {
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const DB_NAME = 'jgjs-pricing-db'
|
||||
const DB_STORE = 'form-state'
|
||||
const DB_KEY = 'xm-info-v3'
|
||||
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 () => {
|
||||
try {
|
||||
const db = await openDB()
|
||||
const tx = db.transaction(DB_STORE, 'readwrite')
|
||||
const store = tx.objectStore(DB_STORE)
|
||||
|
||||
const payload: XmInfoState = {
|
||||
projectName: projectName.value,
|
||||
detailRows: detailRows.value
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
|
||||
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()
|
||||
await localforage.setItem(DB_KEY, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
@ -324,24 +297,8 @@ const saveToIndexedDB = async () => {
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const db = await openDB()
|
||||
const tx = db.transaction(DB_STORE, 'readonly')
|
||||
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()
|
||||
|
||||
const data = await localforage.getItem<XmInfoState>(DB_KEY)
|
||||
console.log(data)
|
||||
if (data) {
|
||||
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
@ -363,24 +320,29 @@ const schedulePersist = () => {
|
||||
}, 250)
|
||||
}
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
// const handleBeforeUnload = () => {
|
||||
// void saveToIndexedDB()
|
||||
// }
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
schedulePersist()
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
watch(projectName, schedulePersist)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
// window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
// window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
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">
|
||||
import { computed, defineAsyncComponent, markRaw, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea ,ScrollBar} from '@/components/ui/scroll-area'
|
||||
import { X } from 'lucide-vue-next'
|
||||
// 组件映射表:所有的动态组件需要在这里注册
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
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> = {
|
||||
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
|
||||
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/ContractDetailView.vue'))),
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<ScrollArea class="flex-1 whitespace-nowrap">
|
||||
<div class="flex gap-1">
|
||||
<div
|
||||
v-for="(tab, index) in tabStore.tabs"
|
||||
:key="tab.id"
|
||||
@click="tabStore.activeTabId = tab.id"
|
||||
: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',
|
||||
tabStore.activeTabId === tab.id
|
||||
? 'bg-background border-border font-medium'
|
||||
: 'border-transparent hover:bg-muted text-muted-foreground'
|
||||
]"
|
||||
>
|
||||
<span class="truncate mr-2">{{ tab.title }}</span>
|
||||
|
||||
<Button
|
||||
v-if="index !== 0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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)"
|
||||
<draggable
|
||||
v-model="tabsModel"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="flex gap-1"
|
||||
:animation="180"
|
||||
:move="canMoveTab"
|
||||
>
|
||||
<template #item="{ element: tab }">
|
||||
<div
|
||||
@click="tabStore.activeTabId = tab.id"
|
||||
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
|
||||
:class="[
|
||||
'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
|
||||
? 'bg-background border-border font-medium'
|
||||
: 'border-transparent hover:bg-muted text-muted-foreground',
|
||||
tab.id !== 'XmView' ? 'cursor-move' : ''
|
||||
]"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="truncate mr-2">{{ tab.title }}</span>
|
||||
|
||||
<Button
|
||||
v-if="tab.id !== 'XmView'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
||||
@click.stop="tabStore.removeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<ScrollBar orientation="horizontal" class="invisible" />
|
||||
</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 class="flex-1 overflow-auto relative">
|
||||
@ -55,8 +344,44 @@ const tabStore = useTabStore()
|
||||
v-show="tabStore.activeTabId === tab.id"
|
||||
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
|
||||
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>
|
||||
</template>
|
||||
@ -57,7 +57,7 @@ const activeComponent = computed(() => {
|
||||
<template>
|
||||
<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="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="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
|
||||
|
||||
@ -3,15 +3,32 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useTabStore = defineStore('tabs', () => {
|
||||
const tabs = ref([
|
||||
{ id: 'XmView', title: '项目卡片', componentName: 'XmView' }
|
||||
interface TabItem<T = Record<string, any>> {
|
||||
id: string; // 标签唯一标识
|
||||
title: string; // 标签标题
|
||||
componentName: string; // 组件名称
|
||||
props?: T; // 传递给组件的 props(可选,泛型适配不同组件)
|
||||
}
|
||||
const defaultTabs :TabItem[]= [
|
||||
{ id: 'XmView', title: '项目卡片', componentName: 'XmView' }
|
||||
]
|
||||
|
||||
const tabs = ref([
|
||||
...defaultTabs
|
||||
])
|
||||
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 exists = tabs.value.find(t => t.id === config.id)
|
||||
const exists = tabs.value.some(t => t.id === config.id)
|
||||
if (!exists) {
|
||||
tabs.value.push(config)
|
||||
tabs.value = [...tabs.value, config]
|
||||
}
|
||||
activeTabId.value = config.id
|
||||
}
|
||||
@ -19,13 +36,72 @@ export const useTabStore = defineStore('tabs', () => {
|
||||
const removeTab = (id: string) => {
|
||||
if (id === 'XmView') return // 首页不可删除
|
||||
const index = tabs.value.findIndex(t => t.id === id)
|
||||
if (activeTabId.value === id) {
|
||||
activeTabId.value = (tabs.value[index - 1]?.id || tabs.value[index + 1]?.id ) as string
|
||||
if (index < 0) return
|
||||
const wasActive = activeTabId.value === id
|
||||
tabs.value = tabs.value.filter(t => t.id !== id)
|
||||
|
||||
if (tabs.value.length === 0) {
|
||||
tabs.value = [...defaultTabs]
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
tabs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return { tabs, activeTabId, openTab, removeTab }
|
||||
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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user