优化目录

This commit is contained in:
wintsa 2026-03-24 17:12:13 +08:00
parent 693a9628bc
commit cd9cffe588
42 changed files with 1122 additions and 969 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useTabStore } from '@/pinia/tab'
import HomeEntryView from '@/components/views/HomeEntryView.vue'
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
import Tab from '@/layout/tab.vue'
import { waitForHydration } from '@/pinia/Plugin/indexdb'

View File

@ -12,6 +12,27 @@ import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
import { useKvStore } from '@/pinia/kv'
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import {
formatDateTime,
isEntryRelatedToAnyContract,
normalizeContractsFromPayload,
normalizeOrder,
type ContractItem
} from '@/features/ht/contracts'
import {
applyImportedContractPiniaPayload,
buildContractPiniaPayload,
readContractRelatedForageEntries,
readContractRelatedKeyedEntries
} from '@/features/ht/importExport'
import type {
HourlyMethodStateLike,
HtFeeMainRowLike,
QuantityMethodStateLike,
RateMethodStateLike,
XmBaseInfoState,
XmScaleState
} from '@/features/ht/types'
import {
cloneJson,
CONTRACT_CONSULT_FACTOR_KEY_PREFIX,
@ -24,16 +45,13 @@ import {
isContractRelatedForageKey,
isContractRelatedKeyedStateKey,
isContractSegmentPackage,
isRecord,
normalizeContractSegmentPackage,
PROJECT_INFO_KEY,
PROJECT_SCALE_KEY,
PRICING_KEY_PREFIXES,
rewriteKeyWithContractId,
SERVICE_KEY_PREFIX,
SERVICE_PRICING_METHODS,
type ContractSegmentPackage,
type DataEntry
type ContractSegmentPackage
} from '@/lib/contractSegment'
import { industryTypeList } from '@/sql'
import { roundTo } from '@/lib/decimal'
@ -55,53 +73,6 @@ import {
ToastViewport
} from 'reka-ui'
interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
interface XmBaseInfoState {
projectIndustry?: string
}
interface XmScaleState {
detailRows?: unknown[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
interface HtFeeMainRowLike {
id?: unknown
}
interface RateMethodStateLike {
budgetFee?: unknown
}
interface HourlyMethodRowLike {
serviceBudget?: unknown
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
}
interface HourlyMethodStateLike {
detailRows?: HourlyMethodRowLike[]
}
interface QuantityMethodRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface QuantityMethodStateLike {
detailRows?: QuantityMethodRowLike[]
}
const STORAGE_KEY = 'ht-card-v1'
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
@ -206,21 +177,6 @@ const budgetRefreshSignature = computed(() => {
.join('|')
})
const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({
...item,
order: index,
createdAt: item.createdAt || new Date().toISOString()
}))
const formatDateTime = (value: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const notify = (text: string) => {
toastTitle.value = '操作成功'
toastText.value = text
@ -570,157 +526,6 @@ const initializeContractScaleData = async (contractId: string) => {
await kvStore.setItem(`${CONTRACT_KEY_PREFIX}${contractId}`, payload)
}
const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.map((item, index) => {
const row = item as Partial<ContractItem>
const name = typeof row.name === 'string' ? row.name.trim() : ''
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
return {
id,
name: name || `导入合同段-${index + 1}`,
order: index,
createdAt
}
})
}
const buildContractPiniaPayload = async (contractIds: string[]) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
const payload = {
contracts: {} as Record<string, unknown>,
servicePricingStates: {} as Record<string, unknown>,
htFeeMainStates: {} as Record<string, unknown>,
htFeeMethodStates: {} as Record<string, unknown>
}
if (idSet.size === 0) return payload
await Promise.all(Array.from(idSet).map(id => zxFwPricingStore.loadContract(id)))
for (const contractId of idSet) {
const contractState = zxFwPricingStore.getContractState(contractId)
if (contractState) {
payload.contracts[contractId] = cloneJson(contractState)
}
const servicePricingState = zxFwPricingStore.servicePricingStates[contractId]
if (isRecord(servicePricingState)) {
payload.servicePricingStates[contractId] = cloneJson(servicePricingState)
}
const mainPrefix = `htExtraFee-${contractId}-`
for (const [mainKey, mainState] of Object.entries(zxFwPricingStore.htFeeMainStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMainStates[mainKey] = cloneJson(mainState)
}
for (const [mainKey, methodState] of Object.entries(zxFwPricingStore.htFeeMethodStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMethodStates[mainKey] = cloneJson(methodState)
}
}
return payload
}
const applyImportedContractPiniaPayload = async (
piniaPayload: unknown,
oldToNewIdMap: Map<string, string>
) => {
if (!isRecord(piniaPayload)) return
const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null
if (!zxFwPayload) return
const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {}
const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {}
const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {}
const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {}
for (const [oldId, newId] of oldToNewIdMap.entries()) {
const rawContractState = contractsMap[oldId]
if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) {
await zxFwPricingStore.setContractState(newId, rawContractState as any)
}
const rawServicePricingByService = servicePricingStatesMap[oldId]
if (isRecord(rawServicePricingByService)) {
for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) {
if (!isRecord(rawServiceMethods)) continue
for (const method of SERVICE_PRICING_METHODS) {
const methodState = rawServiceMethods[method]
if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue
zxFwPricingStore.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true })
}
}
}
const oldMainPrefix = `htExtraFee-${oldId}-`
const newMainPrefix = `htExtraFee-${newId}-`
for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
zxFwPricingStore.setHtFeeMainState(newMainKey, rawMainState as any, { force: true })
}
for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawByRow)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
for (const [rowId, rawByMethod] of Object.entries(rawByRow)) {
if (!isRecord(rawByMethod)) continue
const ratePayload = rawByMethod['rate-fee']
const hourlyPayload = rawByMethod['hourly-fee']
const quantityPayload = rawByMethod['quantity-unit-price-fee']
if (ratePayload != null) {
zxFwPricingStore.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true })
}
if (hourlyPayload != null) {
zxFwPricingStore.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true })
}
if (quantityPayload != null) {
zxFwPricingStore.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true })
}
}
}
}
}
const readContractRelatedForageEntries = async (contractIds: string[]) => {
const keys = await kvStore.keys()
const idSet = new Set(contractIds)
const targetKeys = keys.filter(key => {
for (const id of idSet) {
if (isContractRelatedForageKey(key, id)) return true
}
return false
})
return Promise.all(
targetKeys.map(async key => ({
key,
value: await kvStore.getItem(key)
}))
)
}
const readContractRelatedKeyedEntries = (contractIds: string[]) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
return Object.entries(zxFwPricingStore.keyedStates)
.filter(([key]) => {
for (const id of idSet) {
if (isContractRelatedKeyedStateKey(key, id)) return true
}
return false
})
.map(([key, value]) => ({
key,
value: cloneJson(value)
}))
}
const exportSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) {
window.alert('请先勾选至少一个合同段。')
@ -737,12 +542,14 @@ const exportSelectedContracts = async () => {
}))
const localforageEntries = await readContractRelatedForageEntries(
kvStore,
selectedContracts.map(item => item.id)
)
const keyedEntries = readContractRelatedKeyedEntries(
zxFwPricingStore,
selectedContracts.map(item => item.id)
)
const piniaPayload = await buildContractPiniaPayload(selectedContracts.map(item => item.id))
const piniaPayload = await buildContractPiniaPayload(zxFwPricingStore, selectedContracts.map(item => item.id))
const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) {
@ -825,6 +632,13 @@ const importContractSegments = async (event: Event) => {
const importedEntries = normalizedPackage.localforageEntries
const importedKeyedEntries = normalizedPackage.keyedEntries
const importedContractIdSet = new Set(importedContracts.map(item => String(item.id || '').trim()).filter(Boolean))
const filteredImportedEntries = importedEntries.filter(entry =>
isEntryRelatedToAnyContract(entry.key, importedContractIdSet, isContractRelatedForageKey)
)
const filteredImportedKeyedEntries = importedKeyedEntries.filter(entry =>
isEntryRelatedToAnyContract(entry.key, importedContractIdSet, isContractRelatedKeyedStateKey)
)
const usedIds = new Set(contracts.value.map(item => item.id))
const oldToNewIdMap = new Map<string, string>()
const nextContracts: ContractItem[] = importedContracts.map((item, index) => {
@ -838,7 +652,7 @@ const importContractSegments = async (event: Event) => {
}
})
const rewrittenEntries = importedEntries.map(entry => {
const rewrittenEntries = filteredImportedEntries.map(entry => {
let nextKey = entry.key
for (const [oldId, newId] of oldToNewIdMap.entries()) {
if (!nextKey.includes(oldId)) continue
@ -850,7 +664,7 @@ const importContractSegments = async (event: Event) => {
}
})
const rewrittenKeyedEntries = importedKeyedEntries.map(entry => {
const rewrittenKeyedEntries = filteredImportedKeyedEntries.map(entry => {
let nextKey = entry.key
for (const [oldId, newId] of oldToNewIdMap.entries()) {
if (!nextKey.includes(oldId)) continue
@ -866,7 +680,7 @@ const importContractSegments = async (event: Event) => {
for (const entry of rewrittenKeyedEntries) {
zxFwPricingStore.setKeyState(entry.key, cloneJson(entry.value), { force: true })
}
await applyImportedContractPiniaPayload(normalizedPackage.piniaState, oldToNewIdMap)
await applyImportedContractPiniaPayload(zxFwPricingStore, normalizedPackage.piniaState, oldToNewIdMap)
contracts.value = [...contracts.value, ...nextContracts]
await saveContracts()
@ -1738,214 +1552,5 @@ watch(budgetRefreshSignature, (next, prev) => {
</ToastProvider>
</template>
<style scoped>
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
padding-top: 6px;
}
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
scroll-snap-type: none;
}
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
opacity: 0.35;
}
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
}
.ht-contract-card {
will-change: transform, opacity;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
isolation: isolate;
overflow: visible;
z-index: 0;
transition:
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
border-color 180ms ease;
box-shadow:
0 1px 2px hsl(var(--foreground) / 0.04),
0 6px 16px hsl(var(--foreground) / 0.06);
}
.ht-contract-card::before {
content: '';
position: absolute;
inset: -4px;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
130deg,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.22) 36%,
hsl(var(--foreground) / 0.09) 70%,
transparent 100%
);
opacity: 0;
transition: opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card::after {
content: '';
position: absolute;
left: 6px;
right: 6px;
bottom: -30px;
height: 52px;
border-radius: 999px;
pointer-events: none;
background:
radial-gradient(ellipse at center,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.24) 34%,
hsl(var(--foreground) / 0.20) 58%,
transparent 86%);
filter: blur(18px);
opacity: 0;
transform: translateY(4px);
transition:
opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card:hover {
transform: translate3d(0, -5px, 0);
z-index: 14;
box-shadow:
0 0 0 1.5px hsl(var(--primary) / 0.62),
0 0 28px hsl(var(--primary) / 0.34),
0 0 56px hsl(var(--primary) / 0.22),
0 16px 34px hsl(var(--foreground) / 0.22),
0 32px 60px hsl(var(--foreground) / 0.18);
border-color: hsl(var(--primary) / 0.72);
}
.ht-contract-card:hover::before {
opacity: 1;
}
.ht-contract-card:hover::after {
opacity: 0.95;
transform: translateY(0);
}
.ht-contract-card:active {
transform: translate3d(0, -2px, 0);
box-shadow:
0 5px 12px hsl(var(--foreground) / 0.10),
0 10px 20px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--ready {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.ht-contract-card--enter {
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--ht-card-enter-delay, 0ms);
}
.ht-contract-card--selecting {
transform-origin: 50% 100%;
animation: ht-card-select-wave 2200ms linear infinite both;
animation-delay: var(--ht-card-select-delay, 0ms);
}
.ht-contract-card--selecting:hover {
animation-play-state: paused;
}
.ht-contract-card--selecting.ht-contract-card--selected {
animation: none;
transform: translate3d(0, 0, 0) rotate(0deg);
}
.ht-contract-card--selected {
border-color: hsl(var(--primary));
transform: translate3d(0, -4px, 0);
box-shadow:
0 0 0 1px hsl(var(--primary) / 0.34),
0 12px 24px hsl(var(--primary) / 0.18),
0 22px 36px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--selected::before {
opacity: 1;
}
.ht-contract-card--selected::after {
opacity: 1;
transform: translateY(0);
}
@keyframes ht-card-slide-in {
from {
opacity: 0;
transform: translate3d(44px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes ht-card-select-wave {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
11% {
transform: translate3d(-0.4px, 0, 0) rotate(-0.7deg);
}
22% {
transform: translate3d(-0.9px, 0, 0) rotate(-1.6deg);
}
34% {
transform: translate3d(-1.2px, 0, 0) rotate(-2.3deg);
}
48% {
transform: translate3d(-0.2px, 0, 0) rotate(-0.4deg);
}
62% {
transform: translate3d(0.8px, 0, 0) rotate(1.5deg);
}
76% {
transform: translate3d(1.25px, 0, 0) rotate(2.35deg);
}
88% {
transform: translate3d(0.35px, 0, 0) rotate(0.65deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ht-contract-card--enter,
.ht-contract-card--selecting {
animation: none;
opacity: 1;
transform: none;
}
.ht-contract-card,
.ht-contract-card:hover,
.ht-contract-card:active,
.ht-contract-card--selected {
transition: none;
transform: none;
}
.ht-contract-card::before,
.ht-contract-card::after {
transition: none;
}
}
</style>
<style scoped src="@/features/ht/ht.css"></style>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import HtFeeMethodGrid from '@/components/shared/HtFeeMethodGrid.vue'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { additionalWorkList } from '@/sql'
const props = defineProps<{

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
const props = defineProps<{

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState {

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import HtFeeMethodGrid from '@/components/shared/HtFeeMethodGrid.vue'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { reserveList } from '@/sql'
const props = defineProps<{

View File

@ -200,7 +200,7 @@ const htView = markRaw(
name: 'HtInfoWithProps',
setup() {
const AsyncHtInfo = defineAsyncComponent({
loader: () => import('@/components/ht/htInfo.vue'),
loader: () => import('@/features/ht/components/htInfo.vue'),
onError: (err) => {
console.error('加载 htInfo 组件失败:', err);
}
@ -219,7 +219,7 @@ const zxfwView = markRaw(
name: 'ZxFwWithProps',
setup() {
const AsyncZxFw = defineAsyncComponent({
loader: () => import('@/components/ht/zxFw.vue'),
loader: () => import('@/features/ht/components/zxFw.vue'),
onError: (err) => {
console.error('加载 zxFw 组件失败:', err);
}
@ -238,7 +238,7 @@ const consultCategoryFactorView = markRaw(
name: 'HtConsultCategoryFactorWithProps',
setup() {
const AsyncHtConsultCategoryFactor = defineAsyncComponent({
loader: () => import('@/components/ht/HtConsultCategoryFactor.vue'),
loader: () => import('@/features/ht/components/HtConsultCategoryFactor.vue'),
onError: (err) => {
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
}
@ -257,7 +257,7 @@ const majorFactorView = markRaw(
name: 'HtMajorFactorWithProps',
setup() {
const AsyncHtMajorFactor = defineAsyncComponent({
loader: () => import('@/components/ht/HtMajorFactor.vue'),
loader: () => import('@/features/ht/components/HtMajorFactor.vue'),
onError: (err) => {
console.error('加载 HtMajorFactor 组件失败:', err);
}
@ -278,7 +278,7 @@ const htBaseInfoView = markRaw(
name: 'HtBaseInfoWithProps',
setup() {
const AsyncHtBaseInfo = defineAsyncComponent({
loader: () => import('@/components/ht/HtBaseInfo.vue'),
loader: () => import('@/features/ht/components/HtBaseInfo.vue'),
onError: (err) => {
console.error('加载 HtBaseInfo 组件失败:', err)
}
@ -293,7 +293,7 @@ const additionalWorkFeeView = markRaw(
name: 'HtAdditionalWorkFeeWithProps',
setup() {
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
loader: () => import('@/components/ht/HtAdditionalWorkFee.vue'),
loader: () => import('@/features/ht/components/HtAdditionalWorkFee.vue'),
onError: (err) => {
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
}
@ -308,7 +308,7 @@ const reserveFeeView = markRaw(
name: 'HtReserveFeeWithProps',
setup() {
const AsyncHtReserveFee = defineAsyncComponent({
loader: () => import('@/components/ht/HtReserveFee.vue'),
loader: () => import('@/features/ht/components/HtReserveFee.vue'),
onError: (err) => {
console.error('加载 HtReserveFee 组件失败:', err);
}
@ -323,7 +323,7 @@ const summaryView = markRaw(
name: 'HtContractSummaryWithProps',
setup() {
const AsyncSummary = defineAsyncComponent({
loader: () => import('@/components/ht/HtContractSummary.vue'),
loader: () => import('@/features/ht/components/HtContractSummary.vue'),
onError: (err) => {
console.error('加载 HtContractSummary 组件失败:', err)
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import CommonAgGrid from '@/components/shared/xmCommonAgGrid.vue'
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
const props = defineProps<{

View File

@ -32,7 +32,7 @@ import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue }
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import ServiceCheckboxSelector from '@/components/shared/ServiceCheckboxSelector.vue'
import ServiceCheckboxSelector from '@/features/shared/components/ServiceCheckboxSelector.vue'
interface ServiceItem {
id: string

View File

@ -0,0 +1,50 @@
export interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
export const normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({
...item,
order: index,
createdAt: item.createdAt || new Date().toISOString()
}))
export 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())}`
}
export const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.map((item, index) => {
const row = item as Partial<ContractItem>
const name = typeof row.name === 'string' ? row.name.trim() : ''
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
return {
id,
name: name || `导入合同段-${index + 1}`,
order: index,
createdAt
}
})
}
export const isEntryRelatedToAnyContract = (
key: string,
contractIds: Set<string>,
matcher: (entryKey: string, contractId: string) => boolean
) => {
for (const contractId of contractIds) {
if (matcher(key, contractId)) return true
}
return false
}

210
src/features/ht/ht.css Normal file
View File

@ -0,0 +1,210 @@
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
padding-top: 6px;
}
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
scroll-snap-type: none;
}
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
opacity: 0.35;
}
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
}
.ht-contract-card {
will-change: transform, opacity;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
isolation: isolate;
overflow: visible;
z-index: 0;
transition:
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
border-color 180ms ease;
box-shadow:
0 1px 2px hsl(var(--foreground) / 0.04),
0 6px 16px hsl(var(--foreground) / 0.06);
}
.ht-contract-card::before {
content: '';
position: absolute;
inset: -4px;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
130deg,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.22) 36%,
hsl(var(--foreground) / 0.09) 70%,
transparent 100%
);
opacity: 0;
transition: opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card::after {
content: '';
position: absolute;
left: 6px;
right: 6px;
bottom: -30px;
height: 52px;
border-radius: 999px;
pointer-events: none;
background:
radial-gradient(
ellipse at center,
hsl(var(--primary) / 0.42) 0%,
hsl(var(--primary) / 0.24) 34%,
hsl(var(--foreground) / 0.20) 58%,
transparent 86%
);
filter: blur(18px);
opacity: 0;
transform: translateY(4px);
transition:
opacity 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.ht-contract-card:hover {
transform: translate3d(0, -5px, 0);
z-index: 14;
box-shadow:
0 0 0 1.5px hsl(var(--primary) / 0.62),
0 0 28px hsl(var(--primary) / 0.34),
0 0 56px hsl(var(--primary) / 0.22),
0 16px 34px hsl(var(--foreground) / 0.22),
0 32px 60px hsl(var(--foreground) / 0.18);
border-color: hsl(var(--primary) / 0.72);
}
.ht-contract-card:hover::before {
opacity: 1;
}
.ht-contract-card:hover::after {
opacity: 0.95;
transform: translateY(0);
}
.ht-contract-card:active {
transform: translate3d(0, -2px, 0);
box-shadow:
0 5px 12px hsl(var(--foreground) / 0.10),
0 10px 20px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--ready {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.ht-contract-card--enter {
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--ht-card-enter-delay, 0ms);
}
.ht-contract-card--selecting {
transform-origin: 50% 100%;
animation: ht-card-select-wave 2200ms linear infinite both;
animation-delay: var(--ht-card-select-delay, 0ms);
}
.ht-contract-card--selecting:hover {
animation-play-state: paused;
}
.ht-contract-card--selecting.ht-contract-card--selected {
animation: none;
transform: translate3d(0, 0, 0) rotate(0deg);
}
.ht-contract-card--selected {
border-color: hsl(var(--primary));
transform: translate3d(0, -4px, 0);
box-shadow:
0 0 0 1px hsl(var(--primary) / 0.34),
0 12px 24px hsl(var(--primary) / 0.18),
0 22px 36px hsl(var(--foreground) / 0.10);
}
.ht-contract-card--selected::before {
opacity: 1;
}
.ht-contract-card--selected::after {
opacity: 1;
transform: translateY(0);
}
@keyframes ht-card-slide-in {
from {
opacity: 0;
transform: translate3d(44px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes ht-card-select-wave {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
11% {
transform: translate3d(-0.4px, 0, 0) rotate(-0.7deg);
}
22% {
transform: translate3d(-0.9px, 0, 0) rotate(-1.6deg);
}
34% {
transform: translate3d(-1.2px, 0, 0) rotate(-2.3deg);
}
48% {
transform: translate3d(-0.2px, 0, 0) rotate(-0.4deg);
}
62% {
transform: translate3d(0.8px, 0, 0) rotate(1.5deg);
}
76% {
transform: translate3d(1.25px, 0, 0) rotate(2.35deg);
}
88% {
transform: translate3d(0.35px, 0, 0) rotate(0.65deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ht-contract-card--enter,
.ht-contract-card--selecting {
animation: none;
opacity: 1;
transform: none;
}
.ht-contract-card,
.ht-contract-card:hover,
.ht-contract-card:active,
.ht-contract-card--selected {
transition: none;
transform: none;
}
.ht-contract-card::before,
.ht-contract-card::after {
transition: none;
}
}

View File

@ -0,0 +1,172 @@
import {
cloneJson,
isContractRelatedForageKey,
isContractRelatedKeyedStateKey,
isRecord,
SERVICE_PRICING_METHODS
} from '@/lib/contractSegment'
type AnyRecord = Record<string, unknown>
export interface KvStoreLike {
keys: () => Promise<string[]>
getItem: (key: string) => Promise<unknown>
}
export interface ZxFwPricingStoreLike {
contracts: Record<string, unknown>
servicePricingStates: Record<string, unknown>
htFeeMainStates: Record<string, unknown>
htFeeMethodStates: Record<string, unknown>
keyedStates: Record<string, unknown>
loadContract: (...args: any[]) => Promise<unknown>
getContractState: (...args: any[]) => unknown
setContractState: (...args: any[]) => Promise<unknown>
setServicePricingMethodState: (...args: any[]) => unknown
setHtFeeMainState: (...args: any[]) => unknown
setHtFeeMethodState: (...args: any[]) => unknown
setKeyState: (...args: any[]) => unknown
}
export const buildContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
const payload = {
contracts: {} as AnyRecord,
servicePricingStates: {} as AnyRecord,
htFeeMainStates: {} as AnyRecord,
htFeeMethodStates: {} as AnyRecord
}
if (idSet.size === 0) return payload
await Promise.all(Array.from(idSet).map(id => store.loadContract(id)))
for (const contractId of idSet) {
const contractState = store.getContractState(contractId)
if (contractState) {
payload.contracts[contractId] = cloneJson(contractState)
}
const servicePricingState = store.servicePricingStates[contractId]
if (isRecord(servicePricingState)) {
payload.servicePricingStates[contractId] = cloneJson(servicePricingState)
}
const mainPrefix = `htExtraFee-${contractId}-`
for (const [mainKey, mainState] of Object.entries(store.htFeeMainStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMainStates[mainKey] = cloneJson(mainState)
}
for (const [mainKey, methodState] of Object.entries(store.htFeeMethodStates)) {
if (!mainKey.startsWith(mainPrefix)) continue
payload.htFeeMethodStates[mainKey] = cloneJson(methodState)
}
}
return payload
}
export const applyImportedContractPiniaPayload = async (
store: ZxFwPricingStoreLike,
piniaPayload: unknown,
oldToNewIdMap: Map<string, string>
) => {
if (!isRecord(piniaPayload)) return
const zxFwPayload = isRecord(piniaPayload.zxFwPricing) ? piniaPayload.zxFwPricing : null
if (!zxFwPayload) return
const contractsMap = isRecord(zxFwPayload.contracts) ? zxFwPayload.contracts : {}
const servicePricingStatesMap = isRecord(zxFwPayload.servicePricingStates) ? zxFwPayload.servicePricingStates : {}
const htFeeMainStatesMap = isRecord(zxFwPayload.htFeeMainStates) ? zxFwPayload.htFeeMainStates : {}
const htFeeMethodStatesMap = isRecord(zxFwPayload.htFeeMethodStates) ? zxFwPayload.htFeeMethodStates : {}
for (const [oldId, newId] of oldToNewIdMap.entries()) {
const rawContractState = contractsMap[oldId]
if (isRecord(rawContractState) && Array.isArray(rawContractState.detailRows)) {
await store.setContractState(newId, rawContractState as any)
}
const rawServicePricingByService = servicePricingStatesMap[oldId]
if (isRecord(rawServicePricingByService)) {
for (const [serviceId, rawServiceMethods] of Object.entries(rawServicePricingByService)) {
if (!isRecord(rawServiceMethods)) continue
for (const method of SERVICE_PRICING_METHODS) {
const methodState = rawServiceMethods[method]
if (!isRecord(methodState) || !Array.isArray(methodState.detailRows)) continue
store.setServicePricingMethodState(newId, serviceId, method, methodState as any, { force: true })
}
}
}
const oldMainPrefix = `htExtraFee-${oldId}-`
const newMainPrefix = `htExtraFee-${newId}-`
for (const [oldMainKey, rawMainState] of Object.entries(htFeeMainStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawMainState) || !Array.isArray(rawMainState.detailRows)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
store.setHtFeeMainState(newMainKey, rawMainState as any, { force: true })
}
for (const [oldMainKey, rawByRow] of Object.entries(htFeeMethodStatesMap)) {
if (!oldMainKey.startsWith(oldMainPrefix)) continue
if (!isRecord(rawByRow)) continue
const newMainKey = oldMainKey.replace(oldMainPrefix, newMainPrefix)
for (const [rowId, rawByMethod] of Object.entries(rawByRow)) {
if (!isRecord(rawByMethod)) continue
const ratePayload = rawByMethod['rate-fee']
const hourlyPayload = rawByMethod['hourly-fee']
const quantityPayload = rawByMethod['quantity-unit-price-fee']
if (ratePayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'rate-fee', cloneJson(ratePayload), { force: true })
}
if (hourlyPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'hourly-fee', cloneJson(hourlyPayload), { force: true })
}
if (quantityPayload != null) {
store.setHtFeeMethodState(newMainKey, rowId, 'quantity-unit-price-fee', cloneJson(quantityPayload), { force: true })
}
}
}
}
}
export const readContractRelatedForageEntries = async (
kvStore: KvStoreLike,
contractIds: string[]
) => {
const keys = await kvStore.keys()
const idSet = new Set(contractIds)
const targetKeys = keys.filter(key => {
for (const id of idSet) {
if (isContractRelatedForageKey(key, id)) return true
}
return false
})
return Promise.all(
targetKeys.map(async key => ({
key,
value: await kvStore.getItem(key)
}))
)
}
export const readContractRelatedKeyedEntries = (
store: Pick<ZxFwPricingStoreLike, 'keyedStates'>,
contractIds: string[]
) => {
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
return Object.entries(store.keyedStates)
.filter(([key]) => {
for (const id of idSet) {
if (isContractRelatedKeyedStateKey(key, id)) return true
}
return false
})
.map(([key, value]) => ({
key,
value: cloneJson(value)
}))
}

39
src/features/ht/types.ts Normal file
View File

@ -0,0 +1,39 @@
export interface XmBaseInfoState {
projectIndustry?: string
}
export interface XmScaleState {
detailRows?: unknown[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
export interface HtFeeMainRowLike {
id?: unknown
}
export interface RateMethodStateLike {
budgetFee?: unknown
}
export interface HourlyMethodRowLike {
serviceBudget?: unknown
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
}
export interface HourlyMethodStateLike {
detailRows?: HourlyMethodRowLike[]
}
export interface QuantityMethodRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
export interface QuantityMethodStateLike {
detailRows?: QuantityMethodRowLike[]
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import HourlyFeeGrid from '@/components/shared/HourlyFeeGrid.vue'
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
const props = defineProps<{
contractId: string

View File

@ -14,7 +14,7 @@ import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
import { sumNullableBy } from '@/lib/pricingScaleCalc'
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';

View File

@ -0,0 +1,129 @@
import type localforage from 'localforage'
export interface DataEntry {
key: string
value: any
}
export interface ForageStoreSnapshot {
storeName: string
entries: DataEntry[]
}
export interface DataPackage {
version: number
packageType?: 'project-snapshot'
exportedAt: string
localStorage: DataEntry[]
sessionStorage: DataEntry[]
localforageDefault: DataEntry[]
localforageStores?: ForageStoreSnapshot[]
}
export type ForageInstance = ReturnType<typeof localforage.createInstance>
export type ForageStore = Pick<ForageInstance, 'keys' | 'getItem' | 'setItem' | 'clear'>
type XmInfoLike = {
projectName?: unknown
}
export 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
}
export 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)
}
}
export const toPersistableValue = (value: unknown) => {
try {
return JSON.parse(JSON.stringify(value))
} catch (error) {
console.error('normalize persist value failed, fallback to null:', error)
return null
}
}
export const readForage = async (store: ForageStore): Promise<DataEntry[]> => {
const keys = await store.keys()
const values = await Promise.all(keys.map(key => store.getItem(key)))
return keys.map((key, index) => ({
key,
value: toPersistableValue(values[index])
}))
}
export const writeForage = async (store: ForageStore, entries: DataEntry[]) => {
await store.clear()
await Promise.all((entries || []).map(entry => store.setItem(entry.key, toPersistableValue(entry.value))))
}
export const normalizeEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
}
export const normalizeForageStoreSnapshots = (value: unknown): ForageStoreSnapshot[] => {
if (!Array.isArray(value)) return []
return value
.filter(item =>
item
&& typeof item === 'object'
&& typeof (item as any).storeName === 'string'
&& Array.isArray((item as any).entries)
)
.map(item => ({
storeName: String((item as any).storeName),
entries: normalizeEntries((item as any).entries)
}))
}
export const sanitizeFileNamePart = (value: string): string => {
const cleaned = value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned || '造价项目'
}
export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: string, legacyProjectDbKey: string) => {
const target =
entries.find(item => item.key === projectInfoDbKey) ||
entries.find(item => item.key === legacyProjectDbKey)
const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
}
export const isDataPackageLike = (value: unknown): value is DataPackage => {
if (!value || typeof value !== 'object') return false
const payload = value as Partial<DataPackage>
const hasRequiredArrays =
Array.isArray(payload.localStorage) &&
Array.isArray(payload.sessionStorage) &&
Array.isArray(payload.localforageDefault)
if (!hasRequiredArrays) return false
if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) return false
if (payload.packageType != null && payload.packageType !== 'project-snapshot') return false
return true
}

34
src/features/tab/tab.css Normal file
View File

@ -0,0 +1,34 @@
.tab-strip-sortable > .tab-item {
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
}
.tab-strip-sortable.is-dragging > .tab-item {
will-change: transform;
}
.tab-drag-ghost {
opacity: 0.32;
}
.tab-drag-chosen {
transform: scale(1.015);
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
}
.tab-drag-active {
cursor: grabbing;
}
.tab-strip-scroll-area :deep([data-slot='scroll-area-viewport']) {
scrollbar-width: none;
overflow-y: hidden !important;
}
.tab-strip-scroll-area :deep([data-slot='scroll-area-viewport']::-webkit-scrollbar) {
display: none;
}
.tab-strip-scroll-area :deep([data-slot='scroll-area-scrollbar'][data-orientation='vertical']),
.tab-strip-scroll-area :deep([data-slot='scroll-area-corner']) {
display: none !important;
}

343
src/features/tab/types.ts Normal file
View File

@ -0,0 +1,343 @@
export interface UserGuideStep {
title: string
description: string
points: string[]
}
export type XmInfoLike = {
projectName?: unknown
preparedBy?: unknown
reviewedBy?: unknown
preparedDate?: unknown
projectIndustry?: unknown
preparedCompany?: unknown
overview?: unknown
desc?: unknown
}
export type HtBaseInfoLike = {
quality?: unknown
duration?: unknown
}
export interface ScaleRowLike {
id: string
amount: number | null
landArea: number | null
}
export interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[]
totalAmount?: number
roughCalcEnabled?: boolean
}
export interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
}
export interface ContractCardItem {
id: string
name?: string
order?: number
}
export interface ZxFwRowLike {
id: string
process?: unknown
subtotal?: unknown
finalFee?: unknown
investScale?: unknown
landScale?: unknown
workload?: unknown
hourly?: unknown
}
export interface WorkContentRowLike {
id?: unknown
content?: unknown
checked?: unknown
custom?: unknown
serviceGroup?: unknown
serviceid?: unknown
isAddTrigger?: unknown
}
export interface WorkContentStateLike {
detailRows?: WorkContentRowLike[]
}
export interface ZxFwStorageLike {
selectedIds?: string[]
selectedCodes?: string[]
detailRows?: ZxFwRowLike[]
}
export interface ScaleMethodRowLike extends ScaleRowLike {
benchmarkBudgetBasicChecked?: unknown
benchmarkBudgetOptionalChecked?: unknown
basicFormula?: unknown
optionalFormula?: unknown
budgetFee?: unknown
budgetFeeBasic?: unknown
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
workStageFactor?: unknown
workRatio?: unknown
remark?: unknown
}
export interface HtFeeMainRowLike {
id?: unknown
name?: unknown
}
export interface RateMethodRowLike {
rate?: unknown
budgetFee?: unknown
}
export interface QuantityMethodRowLike {
id?: unknown
feeItem?: unknown
unit?: unknown
quantity?: unknown
unitPrice?: unknown
budgetFee?: number | null
remark?: unknown
}
export interface WorkloadMethodRowLike {
id: string
budgetAdoptedUnitPrice?: unknown
workload?: unknown
basicFee?: unknown
consultCategoryFactor?: unknown
serviceFee?: unknown
remark?: unknown
}
export interface HourlyMethodRowLike {
id: string
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
remark?: unknown
}
export interface DetailRowsStorageLike<T> {
detailRows?: T[]
roughCalcEnabled?: boolean
totalAmount?: number
}
export interface FactorRowLike {
id: string
standardFactor?: unknown
budgetValue?: unknown
remark?: unknown
}
export interface ExportScaleRow {
majorid: number
major: number
cost: number | null
area: number | null
}
export interface ExportMethod1Detail {
proNum: number
major: number
cost: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
export interface ExportMethod1 {
proAmount: number
cost: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod1Detail[]
}
export interface ExportMethod2Detail {
proNum: number
major: number
area: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
export interface ExportMethod2 {
proAmount: number
area: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod2Detail[]
}
export interface ExportMethod3Detail {
task: number
price: number
amount: number
basicFee: number
serviceCoe: number
fee: number
remark: string
}
export interface ExportMethod3 {
basicFee: number
fee: number
det: ExportMethod3Detail[]
}
export interface ExportMethod4Detail {
expert: number
price: number
person_num: number
work_day: number
fee: number
remark: string
}
export interface ExportMethod4 {
person_num: number
work_day: number
fee: number
det: ExportMethod4Detail[]
}
export interface ExportService {
id: number
fee: number
finalFee: number
process: number
tasks: ExportTaskGroup[]
method1?: ExportMethod1
method2?: ExportMethod2
method3?: ExportMethod3
method4?: ExportMethod4
}
export interface ExportTaskGroup {
serviceid?: number
text: string[]
}
export interface ExportServiceCoe {
serviceid: number
coe: number
remark: string
}
export interface ExportMajorCoe {
majorid: number
coe: number
remark: string
}
export interface ExportContract {
name: string
serviceFee: number
addtionalFee: number
reserveFee: number
fee: number
quality: string
duration: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
services: ExportService[]
addtional: ExportAdditional | null
reserve: ExportReserve | null
}
export interface ExportMethod0 {
coe: number
fee: number
}
export interface ExportMethod5Detail {
name: string
unit: string
amount: number
price: number
fee: number
remark: string
}
export interface ExportMethod5 {
fee: number
det: ExportMethod5Detail[]
}
export interface ExportAdditionalDetail {
id: number | string
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
export interface ExportAdditional {
code?: unknown
name: string
fee: number
det: ExportAdditionalDetail[]
}
export interface ExportReserve {
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
export interface ExportReportPayload {
name: string
writer: string
reviewer: string
company: string
date: string
industry: number
fee: number
scaleCost: number
overview: string
desc: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
contracts: ExportContract[]
}

View File

@ -13,9 +13,9 @@
<script setup lang="ts">
import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue'
import TypeLine from '@/layout/typeLine.vue'
import HtFeeGrid from '@/components/shared/HtFeeGrid.vue'
import HtFeeRateMethodForm from '@/components/ht/HtFeeRateMethodForm.vue'
import HourlyFeeGrid from '@/components/shared/HourlyFeeGrid.vue'
import HtFeeGrid from '@/features/shared/components/HtFeeGrid.vue'
import HtFeeRateMethodForm from '@/features/ht/components/HtFeeRateMethodForm.vue'
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
interface TypeLineCategoryItem {
key: string
@ -103,7 +103,7 @@ const workContentPane = markRaw(
name: 'WorkContentPane',
setup() {
const AsyncWorkContentGrid = defineAsyncComponent({
loader: () => import('@/components/shared/WorkContentGrid.vue'),
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
onError: err => {
console.error('加载 WorkContentGrid 组件失败:', err)
}

View File

@ -13,7 +13,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
import TypeLine from '@/layout/typeLine.vue'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
interface ServiceMethodType {
scale?: boolean | null
@ -59,7 +59,7 @@ const createPricingPane = (name: string) =>
name,
setup() {
const AsyncPricingView = defineAsyncComponent({
loader: () => import(`@/components/pricing/${name}.vue`),
loader: () => import(`@/features/pricing/components/${name}.vue`),
onError: err => {
console.error('加载 PricingMethodView 组件失败:', err)
}
@ -94,7 +94,7 @@ const workContentPane = markRaw(
name: 'WorkContentPane',
setup() {
const AsyncWorkContentGrid = defineAsyncComponent({
loader: () => import('@/components/shared/WorkContentGrid.vue'),
loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
onError: err => {
console.error('加载 WorkContentGrid 组件失败:', err)
}

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState {

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/components/shared/XmFactorGrid.vue'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
import { useKvStore } from '@/pinia/kv'
interface XmBaseInfoState {

View File

@ -11,14 +11,14 @@
<script setup lang="ts">
import { defineAsyncComponent, markRaw } from 'vue'
import TypeLine from '@/layout/typeLine.vue'
const infoView = markRaw(defineAsyncComponent(() => import('@/components/xm/info.vue')))
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/components/xm/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/components/ht/Ht.vue')))
const infoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/info.vue')))
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/features/ht/components/Ht.vue')))
const consultCategoryFactorView = markRaw(
defineAsyncComponent(() => import('@/components/xm/XmConsultCategoryFactor.vue'))
defineAsyncComponent(() => import('@/features/xm/components/XmConsultCategoryFactor.vue'))
)
const majorFactorView = markRaw(
defineAsyncComponent(() => import('@/components/xm/XmMajorFactor.vue'))
defineAsyncComponent(() => import('@/features/xm/components/XmMajorFactor.vue'))
)
const xmCategories = [

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import CommonAgGrid from '@/components/shared/xmCommonAgGrid.vue'
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
const DB_KEY = 'xm-info-v3'

View File

@ -30,6 +30,61 @@ import {
} from 'reka-ui'
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
import { formatExportTimestamp } from '@/lib/contractSegment'
import {
getExportProjectName,
isDataPackageLike,
normalizeEntries,
normalizeForageStoreSnapshots,
readForage,
readWebStorage,
sanitizeFileNamePart,
writeForage,
writeWebStorage,
type DataPackage,
type ForageInstance,
type ForageStore
} from '@/features/tab/importExport'
import type {
ContractCardItem,
DetailRowsStorageLike,
ExportAdditional,
ExportAdditionalDetail,
ExportContract,
ExportMajorCoe,
ExportMethod0,
ExportMethod1,
ExportMethod1Detail,
ExportMethod2,
ExportMethod2Detail,
ExportMethod3,
ExportMethod3Detail,
ExportMethod4,
ExportMethod4Detail,
ExportMethod5,
ExportMethod5Detail,
ExportReportPayload,
ExportReserve,
ExportScaleRow,
ExportService,
ExportServiceCoe,
ExportTaskGroup,
FactorRowLike,
HourlyMethodRowLike,
HtBaseInfoLike,
HtFeeMainRowLike,
QuantityMethodRowLike,
RateMethodRowLike,
ScaleMethodRowLike,
ScaleRowLike,
UserGuideStep,
WorkContentStateLike,
WorkloadMethodRowLike,
XmInfoLike,
XmInfoStorageLike,
XmScaleStorageLike,
ZxFwRowLike,
ZxFwStorageLike
} from '@/features/tab/types'
import {
PROJECT_TAB_ID,
QUICK_TAB_ID,
@ -65,372 +120,6 @@ import {
} from '@/lib/reportExportBuilders'
import { exportFile } from '@/sql'
interface DataEntry {
key: string
value: any
}
interface ForageStoreSnapshot {
storeName: string
entries: DataEntry[]
}
interface DataPackage {
version: number
exportedAt: string
localStorage: DataEntry[]
sessionStorage: DataEntry[]
localforageDefault: DataEntry[]
localforageStores?: ForageStoreSnapshot[]
}
interface UserGuideStep {
title: string
description: string
points: string[]
}
type XmInfoLike = {
projectName?: unknown
preparedBy?: unknown
reviewedBy?: unknown
preparedDate?: unknown
projectIndustry?: unknown
preparedCompany?: unknown
overview?: unknown
desc?: unknown
}
type HtBaseInfoLike = {
quality?: unknown
duration?: unknown
}
interface ScaleRowLike {
id: string
amount: number | null
landArea: number | null
}
interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[],
totalAmount?: number,
roughCalcEnabled?: boolean
}
interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
}
interface ContractCardItem {
id: string
name?: string
order?: number
}
interface ZxFwRowLike {
id: string
process?: unknown
subtotal?: unknown
finalFee?: unknown
investScale?: unknown
landScale?: unknown
workload?: unknown
hourly?: unknown
}
interface WorkContentRowLike {
id?: unknown
content?: unknown
checked?: unknown
custom?: unknown
serviceGroup?: unknown
serviceid?: unknown
isAddTrigger?: unknown
}
interface WorkContentStateLike {
detailRows?: WorkContentRowLike[]
}
interface ZxFwStorageLike {
selectedIds?: string[]
selectedCodes?: string[]
detailRows?: ZxFwRowLike[]
}
interface ScaleMethodRowLike extends ScaleRowLike {
benchmarkBudgetBasicChecked?: unknown
benchmarkBudgetOptionalChecked?: unknown
basicFormula?: unknown
optionalFormula?: unknown
budgetFee?: unknown
budgetFeeBasic?: unknown
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
workStageFactor?: unknown
workRatio?: unknown
remark?: unknown
}
interface HtFeeMainRowLike {
id?: unknown
name?: unknown
}
interface RateMethodRowLike {
rate?: unknown
budgetFee?: unknown
}
interface QuantityMethodRowLike {
id?: unknown
feeItem?: unknown
unit?: unknown
quantity?: unknown
unitPrice?: unknown
budgetFee?: number|null
remark?: unknown
}
interface WorkloadMethodRowLike {
id: string
budgetAdoptedUnitPrice?: unknown
workload?: unknown
basicFee?: unknown
consultCategoryFactor?: unknown
serviceFee?: unknown
remark?: unknown
}
interface HourlyMethodRowLike {
id: string
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
remark?: unknown
}
interface DetailRowsStorageLike<T> {
detailRows?: T[],
roughCalcEnabled?: boolean,
totalAmount?: number,
}
interface FactorRowLike {
id: string
standardFactor?: unknown
budgetValue?: unknown
remark?: unknown
}
interface ExportScaleRow {
majorid: number
major: number
cost: number | null
area: number | null
}
interface ExportMethod1Detail {
proNum: number
major: number
cost: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
interface ExportMethod1 {
proAmount: number
cost: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod1Detail[]
}
interface ExportMethod2Detail {
proNum: number
major: number
area: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
processCoe: number
proportion: number
fee: number
remark: string
}
interface ExportMethod2 {
proAmount: number
area: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod2Detail[]
}
interface ExportMethod3Detail {
task: number
price: number
amount: number
basicFee: number
serviceCoe: number
fee: number
remark: string
}
interface ExportMethod3 {
basicFee: number
fee: number
det: ExportMethod3Detail[]
}
interface ExportMethod4Detail {
expert: number
price: number
person_num: number
work_day: number
fee: number
remark: string
}
interface ExportMethod4 {
person_num: number
work_day: number
fee: number
det: ExportMethod4Detail[]
}
interface ExportService {
id: number
fee: number
finalFee: number
process: number
tasks: ExportTaskGroup[]
method1?: ExportMethod1
method2?: ExportMethod2
method3?: ExportMethod3
method4?: ExportMethod4
}
interface ExportTaskGroup {
serviceid?: number
text: string[]
}
interface ExportServiceCoe {
serviceid: number
coe: number
remark: string
}
interface ExportMajorCoe {
majorid: number
coe: number
remark: string
}
interface ExportContract {
name: string
serviceFee: number
addtionalFee: number
reserveFee: number
fee: number
quality: string
duration: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
services: ExportService[]
addtional: ExportAdditional | null
reserve: ExportReserve | null
}
interface ExportMethod0 {
coe: number
fee: number
}
interface ExportMethod5Detail {
name: string
unit: string
amount: number
price: number
fee: number
remark: string
}
interface ExportMethod5 {
fee: number
det: ExportMethod5Detail[]
}
interface ExportAdditionalDetail {
id: number | string
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
interface ExportAdditional {
code?: unknown
name: string
fee: number
det: ExportAdditionalDetail[]
}
interface ExportReserve {
code?: unknown
name: string
fee: number
tasks: ExportTaskGroup[]
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
interface ExportReportPayload {
name: string
writer: string
reviewer: string
company: string
date: string
industry: number
fee: number
scaleCost: number
overview: string
desc: string
scale: ExportScaleRow[]
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
contracts: ExportContract[]
}
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
@ -516,11 +205,11 @@ const userGuideSteps: UserGuideStep[] = [
]
const componentMap: Record<string, any> = {
ProjectCalcView: markRaw(defineAsyncComponent(() => import('@/components/xm/xmCard.vue'))),
QuickCalcView: markRaw(defineAsyncComponent(() => import('@/components/ht/htCard.vue'))),
QuickCalcWorkbenchView: markRaw(defineAsyncComponent(() => import('@/components/views/QuickCalcWorkbenchView.vue'))),
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/components/views/HtFeeMethodTypeLineView.vue'))),
ProjectCalcView: markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmCard.vue'))),
QuickCalcView: markRaw(defineAsyncComponent(() => import('@/features/ht/components/htCard.vue'))),
QuickCalcWorkbenchView: markRaw(defineAsyncComponent(() => import('@/features/workbench/components/QuickCalcWorkbenchView.vue'))),
ZxFwView: markRaw(defineAsyncComponent(() => import('@/features/workbench/components/ZxFwView.vue'))),
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/features/workbench/components/HtFeeMethodTypeLineView.vue'))),
}
const tabStore = useTabStore()
@ -936,36 +625,6 @@ const scheduleRestoreTabInnerScrollTop = (tabId?: string | null) => {
})
}
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)
}
}
type ForageInstance = ReturnType<typeof localforage.createInstance>
type ForageStore = Pick<ForageInstance, 'keys' | 'getItem' | 'setItem' | 'clear'>
const createForageStore = (storeName: string): ForageInstance =>
localforage.createInstance({
name: PINIA_PERSIST_DB_NAME,
@ -983,70 +642,6 @@ const getPiniaPersistStores = () =>
}
})
const readForage = async (store: ForageStore): 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: toPersistableValue(value) })
}
return entries
}
const toPersistableValue = (value: unknown) => {
try {
return JSON.parse(JSON.stringify(value))
} catch (error) {
console.error('normalize persist value failed, fallback to null:', error)
return null
}
}
const writeForage = async (store: ForageStore, entries: DataEntry[]) => {
await store.clear()
for (const entry of entries || []) {
await store.setItem(entry.key, toPersistableValue(entry.value))
}
}
const normalizeEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
}
const normalizeForageStoreSnapshots = (value: unknown): ForageStoreSnapshot[] => {
if (!Array.isArray(value)) return []
return value
.filter(item =>
item
&& typeof item === 'object'
&& typeof (item as any).storeName === 'string'
&& Array.isArray((item as any).entries)
)
.map(item => ({
storeName: String((item as any).storeName),
entries: normalizeEntries((item as any).entries)
}))
}
const sanitizeFileNamePart = (value: string): string => {
const cleaned = value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned || '造价项目'
}
const getExportProjectName = (entries: DataEntry[]): string => {
const target =
entries.find(item => item.key === PROJECT_INFO_DB_KEY) ||
entries.find(item => item.key === LEGACY_PROJECT_DB_KEY)
const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
}
const loadFactorRowsState = async (storageKey: string) => {
const [piniaData, kvData] = await Promise.all([
zxFwPricingStore.loadKeyState<DetailRowsStorageLike<FactorRowLike>>(storageKey),
@ -1390,6 +985,7 @@ const exportData = async () => {
)
const payload: DataPackage = {
version: 2,
packageType: 'project-snapshot',
exportedAt: now.toISOString(),
localStorage: readWebStorage(localStorage),
sessionStorage: readWebStorage(sessionStorage),
@ -1404,7 +1000,7 @@ const exportData = async () => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const projectName = getExportProjectName(payload.localforageDefault)
const projectName = getExportProjectName(payload.localforageDefault, PROJECT_INFO_DB_KEY, LEGACY_PROJECT_DB_KEY)
const timestamp = formatExportTimestamp(now)
link.download = `${projectName}-${timestamp}${ZW_FILE_EXTENSION}`
document.body.appendChild(link)
@ -1447,6 +1043,9 @@ const prepareImportPayloadFromFile = async (file: File) => {
}
const buffer = await file.arrayBuffer()
const payload = await decodeZwArchive<DataPackage>(buffer)
if (!isDataPackageLike(payload)) {
throw new Error('INVALID_DATA_PACKAGE')
}
pendingImportPayload.value = payload
pendingImportFileName.value = file.name
importConfirmOpen.value = true
@ -1956,39 +1555,4 @@ watch(
</ToastProvider>
</template>
<style scoped>
.tab-strip-sortable>.tab-item {
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
}
.tab-strip-sortable.is-dragging>.tab-item {
will-change: transform;
}
.tab-drag-ghost {
opacity: 0.32;
}
.tab-drag-chosen {
transform: scale(1.015);
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
}
.tab-drag-active {
cursor: grabbing;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
scrollbar-width: none;
overflow-y: hidden !important;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]::-webkit-scrollbar) {
display: none;
}
.tab-strip-scroll-area :deep([data-slot="scroll-area-scrollbar"][data-orientation="vertical"]),
.tab-strip-scroll-area :deep([data-slot="scroll-area-corner"]) {
display: none !important;
}
</style>
<style scoped src="@/features/tab/tab.css"></style>

View File

@ -160,6 +160,9 @@ export const syncContractScaleToPricing = async (
await store.loadContract(contractId)
const currentState = store.getContractState(contractId)
const selectedIds = Array.from(new Set((currentState?.selectedIds || []).map(id => String(id || '').trim()).filter(Boolean)))
const changedRowIdSet = options?.changedRowIds?.length
? normalizeChangedScaleRowIds(options.changedRowIds)
: undefined
if (selectedIds.length === 0) {
return {
updatedServiceCount: 0,
@ -167,6 +170,13 @@ export const syncContractScaleToPricing = async (
updatedRowCount: 0
}
}
if (changedRowIdSet && changedRowIdSet.size === 0) {
return {
updatedServiceCount: 0,
updatedMethodCount: 0,
updatedRowCount: 0
}
}
await ensurePricingMethodDetailRowsForServices({
contractId,
@ -179,9 +189,6 @@ export const syncContractScaleToPricing = async (
const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const onlyCostScaleFallbackAmount = calcOnlyCostScaleAmountFromRows(sourceRows)
const changedRowIdSet = options?.changedRowIds?.length
? normalizeChangedScaleRowIds(options.changedRowIds)
: undefined
const updatedServiceIdSet = new Set<string>()
let updatedMethodCount = 0
let updatedRowCount = 0