This commit is contained in:
zwgczx committed 2026-03-02 16:55:27 +08:00
1 parent 757de9a43f
commit 3950057707
17 files changed
+2681 -202

No files matched your search

+55 -2
View File
@@ -10,6 +10,14 @@ import { useTabStore } from '@/pinia/tab'
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
ToastAction,
ToastDescription,
ToastProvider,
@@ -64,6 +72,8 @@ const editingContractId = ref<string | null>(null)
const toastOpen = ref(false)
const toastTitle = ref('操作成功')
const toastText = ref('')
const deleteConfirmOpen = ref(false)
const pendingDeleteContractId = ref<string | null>(null)
const modalOffset = ref({ x: 0, y: 0 })
let dragStartX = 0
let dragStartY = 0
@@ -125,6 +135,30 @@ const notify = (text: string) => {
})
}
const pendingDeleteContractName = computed(() => {
if (!pendingDeleteContractId.value) return ''
const target = contracts.value.find(item => item.id === pendingDeleteContractId.value)
return target?.name || pendingDeleteContractId.value
})
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
if (!open) pendingDeleteContractId.value = null
}
const requestDeleteContract = (id: string) => {
pendingDeleteContractId.value = id
deleteConfirmOpen.value = true
}
const confirmDeleteContract = async () => {
const id = pendingDeleteContractId.value
if (!id) return
await deleteContract(id)
deleteConfirmOpen.value = false
pendingDeleteContractId.value = null
}
const closeContractDataMenu = () => {
contractDataMenuOpen.value = false
}
@@ -945,7 +979,7 @@ onBeforeUnmount(() => {
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="deleteContract(element.id)"
@click.stop="requestDeleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
@@ -1063,7 +1097,7 @@ onBeforeUnmount(() => {
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="deleteContract(element.id)"
@click.stop="requestDeleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
@@ -1150,6 +1184,25 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] 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">
即将删除“{{ pendingDeleteContractName }}”及其关联咨询服务和计价数据,是否继续?
</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="confirmDeleteContract">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<ToastRoot
v-model:open="toastOpen"
:duration="1800"
@@ -40,13 +40,27 @@ const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.service
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const paneInstanceCreatedAt = Date.now()
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
@@ -10,6 +10,18 @@ import { formatThousands } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from 'reka-ui'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -59,14 +71,14 @@ const pricingPaneReloadStore = usePricingPaneReloadStore()
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
const loadFactorDefaults = async () => {
const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(),
loadMajorFactorMap()
@@ -76,12 +88,30 @@ const ensureFactorDefaultsLoaded = async () => {
factorDefaultsLoaded = true
}
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
await loadFactorDefaults()
}
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
@@ -165,8 +195,8 @@ const buildDefaultRows = (): DetailRow[] => {
majorName: child.name,
amount: null,
benchmarkBudget: null,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(child.id),
consultCategoryFactor: null,
majorFactor: null,
budgetFee: null,
remark: '',
path: [group.id, child.id]
@@ -177,7 +207,12 @@ const buildDefaultRows = (): DetailRow[] => {
}
type SourceRow = Pick<DetailRow, 'id'> & Partial<Pick<DetailRow, 'amount' | 'benchmarkBudget' | 'consultCategoryFactor' | 'majorFactor' | 'budgetFee' | 'remark'>>
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] => {
const mergeWithDictRows = (
rowsFromDb: SourceRow[] | undefined,
options?: { includeAmount?: boolean; includeFactorValues?: boolean }
): DetailRow[] => {
const includeAmount = options?.includeAmount ?? true
const includeFactorValues = options?.includeFactorValues ?? true
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
@@ -191,20 +226,24 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
amount: includeAmount && typeof fromDb.amount === 'number' ? fromDb.amount : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: getDefaultConsultCategoryFactor(),
!includeFactorValues
? null
: typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: getDefaultConsultCategoryFactor(),
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
!includeFactorValues
? null
: typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
@@ -226,21 +265,10 @@ const formatEditableNumber = (params: any) => {
}
const formatConsultCategoryFactor = (params: any) => {
if (params.node?.group) {
const v = getDefaultConsultCategoryFactor()
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
const formatMajorFactor = (params: any) => {
if (params.node?.group) {
const groupId = String(params.node?.key || '')
const v = getDefaultMajorFactorById(groupId)
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
@@ -438,10 +466,13 @@ const saveToIndexedDB = async () => {
const loadFromIndexedDB = async () => {
try {
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
detailRows.value = htData?.detailRows
? mergeWithDictRows(htData.detailRows, { includeAmount: false, includeFactorValues: false })
: buildDefaultRows()
return
}
@@ -453,7 +484,7 @@ const loadFromIndexedDB = async () => {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows)
detailRows.value = mergeWithDictRows(htData.detailRows, { includeAmount: false, includeFactorValues: false })
return
}
@@ -464,6 +495,25 @@ const loadFromIndexedDB = async () => {
}
}
const importContractData = async () => {
try {
// 使用默认数据时,强制读取最新的项目系数(预算取值优先,空值回退标准系数)
await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
await saveToIndexedDB()
} catch (error) {
console.error('importContractData failed:', error)
}
}
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
@@ -521,7 +571,28 @@ const processCellFromClipboard = (params: any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<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>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</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">
将使用合同默认数据覆盖当前投资规模明细,是否继续?
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button @click="importContractData">确认覆盖</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
@@ -10,6 +10,18 @@ import { formatThousands } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from 'reka-ui'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
@@ -60,6 +72,7 @@ const pricingPaneReloadStore = usePricingPaneReloadStore()
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const detailRows = ref<DetailRow[]>([])
const getDefaultConsultCategoryFactor = () =>
@@ -67,8 +80,7 @@ const getDefaultConsultCategoryFactor = () =>
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
const loadFactorDefaults = async () => {
const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(),
loadMajorFactorMap()
@@ -78,6 +90,11 @@ const ensureFactorDefaultsLoaded = async () => {
factorDefaultsLoaded = true
}
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
await loadFactorDefaults()
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
@@ -91,8 +108,21 @@ const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
@@ -167,8 +197,8 @@ const buildDefaultRows = (): DetailRow[] => {
amount: null,
landArea: null,
benchmarkBudget: null,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(child.id),
consultCategoryFactor: null,
majorFactor: null,
budgetFee: null,
remark: '',
path: [group.id, child.id]
@@ -179,7 +209,12 @@ const buildDefaultRows = (): DetailRow[] => {
}
type SourceRow = Pick<DetailRow, 'id'> & Partial<Pick<DetailRow, 'amount' | 'landArea' | 'benchmarkBudget' | 'consultCategoryFactor' | 'majorFactor' | 'budgetFee' | 'remark'>>
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] => {
const mergeWithDictRows = (
rowsFromDb: SourceRow[] | undefined,
options?: { includeScaleValues?: boolean; includeFactorValues?: boolean }
): DetailRow[] => {
const includeScaleValues = options?.includeScaleValues ?? true
const includeFactorValues = options?.includeFactorValues ?? true
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
dbValueMap.set(row.id, row)
@@ -193,21 +228,25 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] =>
return {
...row,
amount: typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
amount: includeScaleValues && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: includeScaleValues && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: getDefaultConsultCategoryFactor(),
!includeFactorValues
? null
: typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: getDefaultConsultCategoryFactor(),
majorFactor:
typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
!includeFactorValues
? null
: typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(row.id),
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
@@ -229,21 +268,10 @@ const formatEditableNumber = (params: any) => {
}
const formatConsultCategoryFactor = (params: any) => {
if (params.node?.group) {
const v = getDefaultConsultCategoryFactor()
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
const formatMajorFactor = (params: any) => {
if (params.node?.group) {
const groupId = String(params.node?.key || '')
const v = getDefaultMajorFactorById(groupId)
if (v == null) return ''
return Number(v).toFixed(2)
}
return formatEditableNumber(params)
}
@@ -444,7 +472,9 @@ const loadFromIndexedDB = async () => {
await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
detailRows.value = htData?.detailRows
? mergeWithDictRows(htData.detailRows, { includeScaleValues: false, includeFactorValues: false })
: buildDefaultRows()
return
}
@@ -456,7 +486,7 @@ const loadFromIndexedDB = async () => {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows)
detailRows.value = mergeWithDictRows(htData.detailRows, { includeScaleValues: false, includeFactorValues: false })
return
}
@@ -467,6 +497,25 @@ const loadFromIndexedDB = async () => {
}
}
const importContractData = async () => {
try {
// 使用默认数据时,强制读取最新的项目系数(预算取值优先,空值回退标准系数)
await loadFactorDefaults()
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
await saveToIndexedDB()
} catch (error) {
console.error('importContractData failed:', error)
}
}
watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => {
@@ -524,7 +573,28 @@ const processCellFromClipboard = (params: any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<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>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</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">
将使用合同默认数据覆盖当前用地规模明细,是否继续?
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button @click="importContractData">确认覆盖</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
@@ -45,6 +45,7 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore()
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
@@ -59,8 +60,21 @@ const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && Date.now() <= skipUntil) return true
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
@@ -78,7 +92,8 @@ const detailRows = ref<DetailRow[]>([])
type taskLite = {
serviceID: number
code: string
code?: string
ref?: string
name: string
basicParam: string
unit: string
@@ -116,11 +131,12 @@ const buildDefaultRows = (): DetailRow[] => {
for (const [order, taskId] of sourceTaskIds.entries()) {
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
if (!task?.code || !task?.name) continue
const taskCode = task?.code || task?.ref || ''
if (!taskCode || !task?.name) continue
const rowId = `task-${taskId}-${order}`
rows.push({
id: rowId,
taskCode: task.code,
taskCode,
taskName: task.name,
unit: task.unit || '',
conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null,
+192 -43
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ComponentPublicInstance, PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
import localforage from 'localforage'
@@ -8,10 +8,18 @@ import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { getPricingMethodTotalsForService, getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals'
import { getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals'
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { Search } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
DialogClose,
DialogContent,
DialogDescription,
@@ -22,6 +30,7 @@ import {
DialogTrigger
} from 'reka-ui'
import { Button } from '@/components/ui/button'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { serviceList } from '@/sql'
import { useTabStore } from '@/pinia/tab'
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
@@ -153,6 +162,8 @@ function handleSnapHostScroll() {
const pickerOpen = ref(false)
const pickerTempIds = ref<string[]>([])
const pickerSearch = ref('')
const clearConfirmOpen = ref(false)
const pendingClearServiceId = ref<string | null>(null)
const dragSelecting = ref(false)
const dragMoved = ref(false)
let dragSelectChecked = false
@@ -170,6 +181,39 @@ const selectedServiceText = computed(() => {
return `${names.slice(0, 2).join('、')} 等 ${names.length} 项`
})
const pendingClearServiceName = computed(() => {
if (!pendingClearServiceId.value) return ''
const row = detailRows.value.find(item => item.id === pendingClearServiceId.value)
if (row) return `${row.code}${row.name}`
const dict = serviceById.get(pendingClearServiceId.value)
if (dict) return `${dict.code}${dict.name}`
return pendingClearServiceId.value
})
const handleClearConfirmOpenChange = (open: boolean) => {
clearConfirmOpen.value = open
}
const requestClearRow = (row: DetailRow) => {
if (isFixedRow(row)) return
pendingClearServiceId.value = row.id
clearConfirmOpen.value = true
}
const confirmClearRow = async () => {
const id = pendingClearServiceId.value
if (!id) return
const row = detailRows.value.find(item => item.id === id)
if (!row || isFixedRow(row)) {
clearConfirmOpen.value = false
pendingClearServiceId.value = null
return
}
await clearRowValues(row)
clearConfirmOpen.value = false
pendingClearServiceId.value = null
}
const filteredServiceDict = computed(() => {
const keyword = pickerSearch.value.trim()
if (!keyword) return serviceDict
@@ -198,6 +242,26 @@ const numericParser = (newValue: any): number | null => {
const valueOrZero = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
const getMethodTotalFromRows = (
rows: DetailRow[],
field: 'investScale' | 'landScale' | 'workload' | 'hourly'
) =>
rows.reduce((sum, row) => {
if (isFixedRow(row)) return sum
return addNumbers(sum, valueOrZero(row[field]))
}, 0)
const getMethodTotal = (field: 'investScale' | 'landScale' | 'workload' | 'hourly') =>
getMethodTotalFromRows(detailRows.value, field)
const getFixedRowSubtotal = () =>
addNumbers(
getMethodTotal('investScale'),
getMethodTotal('landScale'),
getMethodTotal('workload'),
getMethodTotal('hourly')
)
const getPricingPaneStorageKeys = (serviceId: string) => [
`tzGMF-${props.contractId}-${serviceId}`,
`ydGMF-${props.contractId}-${serviceId}`,
@@ -207,9 +271,11 @@ const getPricingPaneStorageKeys = (serviceId: string) => [
const clearPricingPaneValues = async (serviceId: string) => {
const keys = getPricingPaneStorageKeys(serviceId)
const skipUntil = Date.now() + PRICING_CLEAR_SKIP_TTL_MS
const clearIssuedAt = Date.now()
const skipUntil = clearIssuedAt + PRICING_CLEAR_SKIP_TTL_MS
const skipToken = `${clearIssuedAt}:${skipUntil}`
for (const key of keys) {
sessionStorage.setItem(`${PRICING_CLEAR_SKIP_PREFIX}${key}`, String(skipUntil))
sessionStorage.setItem(`${PRICING_CLEAR_SKIP_PREFIX}${key}`, skipToken)
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
}
await Promise.all(keys.map(key => localforage.removeItem(key)))
@@ -222,21 +288,37 @@ const clearRowValues = async (row: DetailRow) => {
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick()
await clearPricingPaneValues(row.id)
const totals = await getPricingMethodTotalsForService({
contractId: props.contractId,
serviceId: row.id
})
detailRows.value = detailRows.value.map(item =>
// const totals = await getPricingMethodTotalsForService({
// contractId: props.contractId,
// serviceId: row.id
// })
const clearedRows = detailRows.value.map(item =>
item.id !== row.id
? item
: {
...item,
investScale: totals.investScale,
landScale: totals.landScale,
workload: totals.workload,
hourly: totals.hourly
investScale: null,
landScale: null,
workload: null,
hourly: null
}
)
const nextInvestScale = getMethodTotalFromRows(clearedRows, 'investScale')
const nextLandScale = getMethodTotalFromRows(clearedRows, 'landScale')
const nextWorkload = getMethodTotalFromRows(clearedRows, 'workload')
const nextHourly = getMethodTotalFromRows(clearedRows, 'hourly')
detailRows.value = clearedRows.map(item =>
isFixedRow(item)
? {
...item,
investScale: nextInvestScale,
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: addNumbers(nextInvestScale, nextLandScale, nextWorkload, nextHourly)
}
: item
)
await saveToIndexedDB()
}
@@ -249,6 +331,41 @@ const openEditTab = (row: DetailRow) => {
})
}
const ActionCellRenderer = defineComponent({
name: 'ActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<DetailRow>>,
required: true
}
},
setup(props) {
return () => {
if (isFixedRow(props.params.data)) return null
return h('div', { class: 'zxfw-action-wrap' }, [
h(TooltipRoot, null, {
default: () => [
h(TooltipTrigger, { asChild: true }, {
default: () =>
h('button', { class: 'zxfw-action-btn', 'data-action': 'edit' }, '✏️')
}),
h(TooltipContent, { side: 'top' }, { default: () => '编辑' })
]
}),
h(TooltipRoot, null, {
default: () => [
h(TooltipTrigger, { asChild: true }, {
default: () =>
h('button', { class: 'zxfw-action-btn', 'data-action': 'clear' }, '🧹')
}),
h(TooltipContent, { side: 'top' }, { default: () => '清空' })
]
})
])
}
}
})
const columnDefs: ColDef<DetailRow>[] = [
{ headerName: '编码', field: 'code', minWidth: 50, maxWidth: 100 },
{ headerName: '名称', field: 'name', minWidth: 250, flex: 3, tooltipField: 'name' },
@@ -261,6 +378,11 @@ const columnDefs: ColDef<DetailRow>[] = [
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getMethodTotal('investScale')
return params.data.investScale
},
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
},
@@ -273,6 +395,11 @@ const columnDefs: ColDef<DetailRow>[] = [
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getMethodTotal('landScale')
return params.data.landScale
},
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 2))
},
@@ -285,6 +412,11 @@ const columnDefs: ColDef<DetailRow>[] = [
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getMethodTotal('workload')
return params.data.workload
},
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
@@ -298,6 +430,11 @@ const columnDefs: ColDef<DetailRow>[] = [
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getMethodTotal('hourly')
return params.data.hourly
},
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
@@ -313,6 +450,7 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: false,
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getFixedRowSubtotal()
return addNumbers(
valueOrZero(params.data.investScale),
valueOrZero(params.data.landScale),
@@ -332,14 +470,7 @@ const columnDefs: ColDef<DetailRow>[] = [
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: (params: ICellRendererParams<DetailRow>) =>
isFixedRow(params.data)
? ''
: `<div class="zxfw-action-wrap">
<button class="zxfw-action-btn" data-action="edit" title="编辑">✏️</button>
<button class="zxfw-action-btn" data-action="clear" title="清空(重置到默认状态)">🧹</button>
</div>`
cellRenderer: ActionCellRenderer
}
]
@@ -353,7 +484,7 @@ const detailGridOptions: GridOptions<DetailRow> = {
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
const action = btn?.dataset.action
if (action === 'clear') {
await clearRowValues(params.data)
requestClearRow(params.data)
return
}
if (action === 'edit') {
@@ -457,7 +588,7 @@ const handlePickerOpenChange = (open: boolean) => {
const confirmPicker = async () => {
applySelection(pickerTempIds.value)
try {
await fillPricingTotalsForSelectedRows()
// await fillPricingTotalsForSelectedRows()
await saveToIndexedDB()
} catch (error) {
console.error('confirmPicker failed:', error)
@@ -469,15 +600,7 @@ const clearPickerSelection = () => {
pickerTempIds.value = []
}
const toggleServiceCode = (code: string, checked: boolean) => {
if (checked) {
if (!pickerTempIds.value.includes(code)) {
pickerTempIds.value = [...pickerTempIds.value, code]
}
return
}
pickerTempIds.value = pickerTempIds.value.filter(item => item !== code)
}
const applyTempChecked = (code: string, checked: boolean) => {
const exists = pickerTempIds.value.includes(code)
@@ -608,7 +731,7 @@ const loadFromIndexedDB = async () => {
})
try {
await fillPricingTotalsForSelectedRows()
// await fillPricingTotalsForSelectedRows()
} catch (error) {
console.error('fillPricingTotalsForSelectedRows failed:', error)
}
@@ -659,7 +782,8 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="rootRef" class="space-y-6">
<TooltipProvider>
<div ref="rootRef" class="space-y-6">
<DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange">
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
<div class="mb-2 flex items-center justify-between gap-3">
@@ -668,13 +792,17 @@ onBeforeUnmount(() => {
<div class="flex items-center gap-2">
<input :value="selectedServiceText" readonly placeholder="请点击右侧“浏览”选择服务"
class="h-10 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none" />
<DialogTrigger as-child>
<button type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-md border text-sm hover:bg-accent cursor-pointer"
title="浏览服务词典">
<Search class="h-4 w-4" />
</button>
</DialogTrigger>
<TooltipRoot>
<TooltipTrigger as-child>
<DialogTrigger as-child>
<button type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-md border text-sm hover:bg-accent cursor-pointer">
<Search class="h-4 w-4" />
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="top">浏览服务词典</TooltipContent>
</TooltipRoot>
</div>
</div>
<DialogPortal>
@@ -754,5 +882,26 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] 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">
将清空“{{ pendingClearServiceName }}”的计价数据是否继续?
</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="confirmClearRow">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</TooltipProvider>
</template>