This commit is contained in:
wintsa 2026-02-25 14:26:40 +08:00
parent f121aa233e
commit 5734cfa534
12 changed files with 1584 additions and 134 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -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: {

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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) => {

View 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>

View File

@ -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">
<draggable
v-model="tabsModel"
item-key="id"
tag="div"
class="flex gap-1"
:animation="180"
:move="canMoveTab"
>
<template #item="{ element: tab }">
<div
v-for="(tab, index) in tabStore.tabs"
:key="tab.id"
@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-[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
? '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>
<Button
v-if="index !== 0"
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="tabStore.removeTab(tab.id)"
@click.stop="tabStore.removeTab(tab.id)"
>
<X class="h-3 w-3" />
</Button>
</div>
</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>

View File

@ -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>

View File

@ -3,15 +3,32 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
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' }
]
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
}
tabs.value.splice(index, 1)
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]
}
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: {