This commit is contained in:
wintsa 2026-03-02 11:05:52 +08:00
parent ea6a244942
commit 757de9a43f
9 changed files with 312 additions and 90 deletions

View File

@ -1093,9 +1093,10 @@ onBeforeUnmount(() => {
</ScrollArea>
</div>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
title="回到顶部"
aria-label="回到顶部"
:class="[
'fixed bottom-8 right-8 z-40 inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-black/15 bg-white text-black shadow-[0_10px_24px_rgba(0,0,0,0.16)] transition-all duration-300 hover:scale-105 hover:border-black/30 hover:bg-black hover:text-white',
@ -1105,6 +1106,9 @@ onBeforeUnmount(() => {
>
<ArrowUp class="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="left">回到顶部</TooltipContent>
</TooltipRoot>
<div
v-if="showCreateModal"

View File

@ -260,25 +260,6 @@ const processCellFromClipboard = (params: any) => {
return params.value
}
const fitColumnsToGrid = () => {
if (!gridApi.value) return
requestAnimationFrame(() => {
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 68 })
})
}
const handleGridReady = (event: GridReadyEvent<FactorRow>) => {
gridApi.value = event.api
fitColumnsToGrid()
}
const handleGridSizeChanged = (_event: GridSizeChangedEvent<FactorRow>) => {
fitColumnsToGrid()
}
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<FactorRow>) => {
fitColumnsToGrid()
}
onMounted(async () => {
await loadFromIndexedDB()
@ -321,9 +302,7 @@ onBeforeUnmount(() => {
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@grid-size-changed="handleGridSizeChanged"
@first-data-rendered="handleFirstDataRendered"
/>
</div>
</div>

View File

@ -317,25 +317,7 @@ const processCellFromClipboard = (params:any) => {
return params.value;
};
const fitColumnsToGrid = () => {
if (!gridApi.value) return
requestAnimationFrame(() => {
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 80 })
})
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
fitColumnsToGrid()
}
const handleGridSizeChanged = (_event: GridSizeChangedEvent<DetailRow>) => {
fitColumnsToGrid()
}
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
fitColumnsToGrid()
}
</script>
<template>
@ -370,9 +352,7 @@ const handleFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@grid-size-changed="handleGridSizeChanged"
@first-data-rendered="handleFirstDataRendered"
/>
</div>

View File

@ -220,8 +220,9 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
'editable-cell-empty': params =>{
// console.log(!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === ''))
return !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')}
},
aggFunc: decimalAggSum,
valueParser: params => {
@ -396,25 +397,6 @@ const processCellFromClipboard = (params:any) => {
return params.value;
};
const fitColumnsToGrid = () => {
if (!gridApi.value) return
requestAnimationFrame(() => {
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 80 })
})
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
fitColumnsToGrid()
}
const handleGridSizeChanged = (_event: GridSizeChangedEvent<DetailRow>) => {
fitColumnsToGrid()
}
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
fitColumnsToGrid()
}
const scrollToGridSection = () => {
const target = gridSectionRef.value || agGridRef.value
@ -476,9 +458,6 @@ const scrollToGridSection = () => {
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@grid-size-changed="handleGridSizeChanged"
@first-data-rendered="handleFirstDataRendered"
/>
</div>
</div>

View File

@ -5,7 +5,7 @@ import draggable from 'vuedraggable'
import { useTabStore } from '@/pinia/tab'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { ChevronDown, RotateCcw, X } from 'lucide-vue-next'
import { ChevronDown, CircleHelp, RotateCcw, X } from 'lucide-vue-next'
import localforage from 'localforage'
import {
AlertDialogAction,
@ -33,10 +33,92 @@ interface DataPackage {
localforageDefault: DataEntry[]
}
interface UserGuideStep {
title: string
description: string
points: string[]
}
type XmInfoLike = {
projectName?: unknown
}
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const userGuideSteps: UserGuideStep[] = [
{
title: '欢迎使用',
description: '这个引导会覆盖系统主要功能和完整使用路径,建议按顺序走一遍。',
points: [
'顶部是标签页区,可在项目、合同段、服务计算页之间快速切换。',
'页面里的表格与表单会自动保存到本地,无需手动点击保存。',
'你可以随时点击右上角“使用引导”重新打开本教程。'
]
},
{
title: '项目卡片与四个模块',
description: '默认标签“项目卡片”是入口,左侧流程线包含四个项目级功能。',
points: [
'基础信息:填写项目名称与项目规模明细。',
'合同段管理:新建、排序、搜索、导入/导出合同段。',
'咨询分类系数 / 工程专业系数:维护系数预算取值和备注。'
]
},
{
title: '基础信息填写',
description: '先在“基础信息”中补齐项目级数据,再进入后续合同段计算。',
points: [
'项目名称会用于导出文件名和页面展示。',
'项目明细表支持直接编辑、复制粘贴、撤销重做。',
'分组行自动汇总,顶部固定行显示总合计。'
]
},
{
title: '合同段管理',
description: '在“合同段管理”中完成合同段生命周期操作。',
points: [
'“添加合同段”用于新增,卡片右上角可编辑或删除。',
'支持搜索、网格/列表切换,非搜索状态可拖拽排序。',
'更多菜单可导入/导出合同段;点击卡片进入该合同段详情。'
]
},
{
title: '合同段详情',
description: '进入合同段后,左侧同样是流程线,包含规模信息和咨询服务两部分。',
points: [
'规模信息:按工程专业填写当前合同段的规模数据。',
'咨询服务:选择服务词典并生成服务费用明细。',
'合同段页面会独立缓存,不同合同段互不干扰。'
]
},
{
title: '咨询服务与计算页',
description: '咨询服务页面用于管理服务明细,并进入具体计费方法页面。',
points: [
'先点击“浏览”选择服务,再确认生成明细行。',
'明细表“编辑”可打开服务计算页,“清空”会重置该服务计算结果。',
'服务计算页包含投资规模法、用地规模法、工作量法、工时法四种方法。'
]
},
{
title: '系数维护',
description: '项目级系数用于调节预算取值,可在两个系数页分别维护。',
points: [
'咨询分类系数页:按咨询分类维护预算取值与说明。',
'工程专业系数页:按专业树维护预算取值与说明。',
'支持批量粘贴、撤销重做,便于一次性维护多行数据。'
]
},
{
title: '数据管理与恢复',
description: '顶部工具栏负责全量数据导入导出与初始化重置。',
points: [
'“导入/导出”是整项目级别的数据包操作。',
'“重置”会清空本地全部数据并恢复默认页面。',
'建议在重要调整前先导出备份。'
]
}
]
const componentMap: Record<string, any> = {
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/ContractDetailView.vue'))),
@ -56,6 +138,8 @@ const tabContextRef = ref<HTMLElement | null>(null)
const dataMenuOpen = ref(false)
const dataMenuRef = ref<HTMLElement | null>(null)
const importFileRef = ref<HTMLInputElement | null>(null)
const userGuideOpen = ref(false)
const userGuideStepIndex = ref(0)
const tabItemElMap = new Map<string, HTMLElement>()
const tabPanelElMap = new Map<string, HTMLElement>()
@ -69,6 +153,12 @@ const tabsModel = computed({
const contextTabIndex = computed(() => tabStore.tabs.findIndex(t => t.id === contextTabId.value))
const hasClosableTabs = computed(() => tabStore.tabs.some(t => t.id !== 'XmView'))
const activeGuideStep = computed(
() => userGuideSteps[userGuideStepIndex.value] || userGuideSteps[0]
)
const isFirstGuideStep = computed(() => userGuideStepIndex.value === 0)
const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideSteps.length - 1)
const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.length}`)
const canCloseLeft = computed(() => {
if (contextTabIndex.value <= 0) return false
return tabStore.tabs.slice(0, contextTabIndex.value).some(t => t.id !== 'XmView')
@ -86,6 +176,78 @@ const closeMenus = () => {
dataMenuOpen.value = false
}
const markGuideCompleted = () => {
try {
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
} catch (error) {
console.error('mark guide completed failed:', error)
}
}
const hasGuideCompleted = () => {
try {
return localStorage.getItem(USER_GUIDE_COMPLETED_KEY) === '1'
} catch (error) {
console.error('read guide completion failed:', error)
return false
}
}
const hasNonDefaultTabState = () => {
try {
const raw = localStorage.getItem('tabs')
if (!raw) return false
const parsed = JSON.parse(raw) as { tabs?: Array<{ id?: string }>; activeTabId?: string }
const tabs = Array.isArray(parsed?.tabs) ? parsed.tabs : []
const hasCustomTabs = tabs.some(item => item?.id && item.id !== 'XmView')
const activeTabId = typeof parsed?.activeTabId === 'string' ? parsed.activeTabId : ''
return hasCustomTabs || (activeTabId !== '' && activeTabId !== 'XmView')
} catch (error) {
console.error('parse tabs cache failed:', error)
return false
}
}
const shouldAutoOpenGuide = async () => {
if (hasGuideCompleted()) return false
if (hasNonDefaultTabState()) return false
try {
const keys = await localforage.keys()
return keys.length === 0
} catch (error) {
console.error('read localforage keys failed:', error)
return false
}
}
const openUserGuide = (startAt = 0) => {
closeMenus()
userGuideStepIndex.value = Math.min(Math.max(startAt, 0), userGuideSteps.length - 1)
userGuideOpen.value = true
}
const closeUserGuide = (completed = false) => {
userGuideOpen.value = false
if (completed) markGuideCompleted()
}
const prevUserGuideStep = () => {
if (isFirstGuideStep.value) return
userGuideStepIndex.value -= 1
}
const nextUserGuideStep = () => {
if (isLastGuideStep.value) {
closeUserGuide(true)
return
}
userGuideStepIndex.value += 1
}
const jumpToGuideStep = (stepIndex: number) => {
userGuideStepIndex.value = Math.min(Math.max(stepIndex, 0), userGuideSteps.length - 1)
}
const openTabContextMenu = (event: MouseEvent, tabId: string) => {
contextTabId.value = tabId
tabContextX.value = event.clientX
@ -116,6 +278,24 @@ const handleGlobalMouseDown = (event: MouseEvent) => {
}
}
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (!userGuideOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
closeUserGuide(false)
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
prevUserGuideStep()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
nextUserGuideStep()
}
}
const runTabMenuAction = (action: 'all' | 'left' | 'right' | 'other') => {
if (action === 'all') tabStore.closeAllTabs()
if (action === 'left') tabStore.closeLeftTabs(contextTabId.value)
@ -352,14 +532,21 @@ const handleReset = async () => {
onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown)
window.addEventListener('keydown', handleGlobalKeyDown)
void nextTick(() => {
ensureActiveTabVisible()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
})
void (async () => {
if (await shouldAutoOpenGuide()) {
openUserGuide(0)
}
})()
})
onBeforeUnmount(() => {
window.removeEventListener('mousedown', handleGlobalMouseDown)
window.removeEventListener('keydown', handleGlobalKeyDown)
})
watch(
@ -464,6 +651,11 @@ watch(
/>
</div>
<Button variant="outline" size="sm" class="mb-2 shrink-0 cursor-pointer" @click="openUserGuide(0)">
<CircleHelp class="h-4 w-4 mr-1" />
使用引导
</Button>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button variant="destructive" size="sm" class="mb-2 shrink-0">
@ -538,5 +730,50 @@ watch(
删除其他
</button>
</div>
<div
v-if="userGuideOpen"
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 p-4"
@click.self="closeUserGuide(false)"
>
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
<div class="flex items-start justify-between border-b px-6 py-5">
<div>
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p>
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-4 px-6 py-5">
<p class="text-sm leading-6 text-foreground">{{ activeGuideStep.description }}</p>
<ul class="list-disc space-y-2 pl-5 text-sm text-muted-foreground">
<li v-for="(point, index) in activeGuideStep.points" :key="`${activeGuideStep.title}-${index}`">
{{ point }}
</li>
</ul>
</div>
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-1.5">
<button
v-for="(_step, index) in userGuideSteps"
:key="`guide-dot-${index}`"
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'"
:aria-label="`跳转到第 ${index + 1} 步`"
@click="jumpToGuideStep(index)"
/>
</div>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -37,10 +37,10 @@ export const gridOptions: GridOptions<any> = {
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
autoSizeStrategy: {
type: 'fitGridWidth',
defaultMinWidth: 100,
},
// autoSizeStrategy: {
// type: 'fitGridWidth',
// defaultMinWidth: 100,
// },
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
getDataPath: data => data.path,

View File

@ -2,12 +2,50 @@ import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
import { TreeDataModule ,LicenseManager,CellSelectionModule,ContextMenuModule,ClipboardModule } from 'ag-grid-enterprise';
LicenseManager.setLicenseKey("[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b");
import {
ModuleRegistry,
ClientSideRowModelModule,
ColumnAutoSizeModule,
CsvExportModule,
LargeTextEditorModule,
NumberEditorModule,
PinnedRowModule,
TextEditorModule,
TooltipModule,
UndoRedoEditModule,ValidationModule,LocaleModule ,CellStyleModule ,RowAutoHeightModule
} from 'ag-grid-community'
import {
AggregationModule,
CellSelectionModule,
ClipboardModule,
ContextMenuModule,
LicenseManager,
MenuModule,
RowGroupingModule,
TreeDataModule
} from 'ag-grid-enterprise'
LicenseManager.setLicenseKey("[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b")
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
ModuleRegistry.registerModules([AllCommunityModule,TreeDataModule,CellSelectionModule,ContextMenuModule ,ClipboardModule ]);
ModuleRegistry.registerModules([
ClientSideRowModelModule,
ColumnAutoSizeModule,
CsvExportModule,
TextEditorModule,
NumberEditorModule,RowAutoHeightModule,
LargeTextEditorModule,
UndoRedoEditModule,CellStyleModule ,
PinnedRowModule,
TooltipModule,
TreeDataModule,
AggregationModule,
RowGroupingModule,
MenuModule,
CellSelectionModule,
ContextMenuModule,
ClipboardModule,LocaleModule ,
ValidationModule
])
createApp(App).use(pinia).mount('#app')

View File

@ -226,6 +226,10 @@
}
.xmMx .ag-cell.editable-cell-empty,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value {
.xmMx .ag-cell.editable-cell-empty .ag-cell-wrapper,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value,
.xmMx .ag-cell.editable-cell-empty .ag-cell-value * {
--ag-data-color: #94a3b8;
color: #94a3b8 !important;
font-style: italic;
}

View File

@ -37,6 +37,7 @@ export default defineConfig({
}
}
},
chunkSizeWarningLimit: 800,
// 5. 生产环境是否生成 sourcemap默认 false关闭可减小包体积
sourcemap: false,
// 6. 清空输出目录(默认 true