This commit is contained in:
zwgczx committed 2026-03-03 16:16:16 +08:00
1 parent 62546bc937
commit 33913c29d2
9 files changed
+1153 -239

No files matched your search

+499 -2
View File
@@ -19,6 +19,7 @@ import {
AlertDialogTrigger,
} from 'reka-ui'
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
import { exportFile, serviceList } from '@/sql'
interface DataEntry {
key: string
@@ -43,6 +44,176 @@ type XmInfoLike = {
projectName?: unknown
}
interface ScaleRowLike {
id: string
amount?: unknown
landArea?: unknown
}
interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[]
}
interface ContractCardItem {
id: string
name?: string
order?: number
}
interface ZxFwRowLike {
id: string
subtotal?: unknown
investScale?: unknown
landScale?: unknown
workload?: unknown
hourly?: unknown
}
interface ZxFwStorageLike {
detailRows?: ZxFwRowLike[]
}
interface ScaleMethodRowLike extends ScaleRowLike {
basicFormula?: unknown
optionalFormula?: unknown
budgetFee?: unknown
budgetFeeBasic?: unknown
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
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[]
}
interface ExportScaleRow {
major: number
cost: number
area: number
}
interface ExportMethod1Detail {
major: number
cost: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
fee: number
desc: string
}
interface ExportMethod1 {
cost: number
basicFee: number
basicFee_basic: number
basicFee_optional: number
fee: number
det: ExportMethod1Detail[]
}
interface ExportMethod2Detail {
major: number
area: number
basicFee: number
basicFormula: string
basicFee_basic: number
optionalFormula: string
basicFee_optional: number
serviceCoe: number
majorCoe: number
fee: number
desc: string
}
interface ExportMethod2 {
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
desc: string
}
interface ExportMethod3 {
basicFee: number
fee: number
det: ExportMethod3Detail[]
}
interface ExportMethod4Detail {
expert: number
price: number
person_num: number
work_day: number
fee: number
desc: string
}
interface ExportMethod4 {
person_num: number
work_day: number
fee: number
det: ExportMethod4Detail[]
}
interface ExportService {
id: number
fee: number
method1?: ExportMethod1
method2?: ExportMethod2
method3?: ExportMethod3
method4?: ExportMethod4
}
interface ExportContract {
name: string
fee: number
scale: ExportScaleRow[]
services: ExportService[]
}
interface ExportReportPayload {
name: string
fee: number
scale: ExportScaleRow[]
contracts: ExportContract[]
}
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const userGuideSteps: UserGuideStep[] = [
{
@@ -453,6 +624,321 @@ const getExportProjectName = (entries: DataEntry[]): string => {
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
}
const toFiniteNumber = (value: unknown): number | null => {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0
const toSafeInteger = (value: unknown): number | null => {
const num = Number(value)
if (!Number.isInteger(num)) return null
if (!Number.isSafeInteger(num)) return null
return num
}
const sumNumbers = (values: Array<number | null | undefined>): number =>
values.reduce<number>(
(sum, value) => sum + (typeof value === 'number' && Number.isFinite(value) ? value : 0),
0
)
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0
const getTaskIdFromRowId = (value: string): number | null => {
const match = /^task-(\d+)-\d+$/.exec(value)
return match ? toSafeInteger(match[1]) : null
}
const getExpertIdFromRowId = (value: string): number | null => {
const match = /^expert-(\d+)$/.exec(value)
return match ? toSafeInteger(match[1]) : null
}
const hasServiceId = (serviceId: string) =>
Object.prototype.hasOwnProperty.call(serviceList as Record<string, unknown>, serviceId)
const buildScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
const major = toSafeInteger(row.id)
const cost = toFiniteNumber(row.amount)
const area = toFiniteNumber(row.landArea)
if (major == null || (cost == null && area == null)) return null
return {
major,
cost: cost ?? 0,
area: area ?? 0
}
})
.filter((item): item is ExportScaleRow => Boolean(item))
}
const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
const cost = toFiniteNumber(row.amount)
const basicFee = toFiniteNumber(row.budgetFee)
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
const desc = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
cost != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
isNonEmptyString(desc)
if (!hasValue) return null
return {
major,
cost: cost ?? 0,
basicFee: basicFee ?? 0,
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
basicFee_basic: basicFeeBasic ?? 0,
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
fee: basicFee ?? 0,
desc
}
})
.filter((item): item is ExportMethod1Detail => Boolean(item))
if (det.length === 0) return null
return {
cost: sumNumbers(det.map(item => item.cost)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
basicFee_basic: sumNumbers(det.map(item => item.basicFee_basic)),
basicFee_optional: sumNumbers(det.map(item => item.basicFee_optional)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
const area = toFiniteNumber(row.landArea)
const basicFee = toFiniteNumber(row.budgetFee)
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
const desc = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
area != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
isNonEmptyString(desc)
if (!hasValue) return null
return {
major,
area: area ?? 0,
basicFee: basicFee ?? 0,
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
basicFee_basic: basicFeeBasic ?? 0,
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
fee: basicFee ?? 0,
desc
}
})
.filter((item): item is ExportMethod2Detail => Boolean(item))
if (det.length === 0) return null
return {
area: sumNumbers(det.map(item => item.area)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
basicFee_basic: sumNumbers(det.map(item => item.basicFee_basic)),
basicFee_optional: sumNumbers(det.map(item => item.basicFee_optional)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const task = getTaskIdFromRowId(row.id)
if (task == null) return null
const amount = toFiniteNumber(row.workload)
const basicFee = toFiniteNumber(row.basicFee)
const fee = toFiniteNumber(row.serviceFee)
const desc = typeof row.remark === 'string' ? row.remark : ''
const hasValue = amount != null || basicFee != null || fee != null || isNonEmptyString(desc)
if (!hasValue) return null
return {
task,
price: toFiniteNumberOrZero(row.budgetAdoptedUnitPrice),
amount: amount ?? 0,
basicFee: basicFee ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
fee: fee ?? 0,
desc
}
})
.filter((item): item is ExportMethod3Detail => Boolean(item))
if (det.length === 0) return null
return {
basicFee: sumNumbers(det.map(item => item.basicFee)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 | null => {
if (!Array.isArray(rows)) return null
const det = rows
.map(row => {
const expert = getExpertIdFromRowId(row.id)
if (expert == null) return null
const personNum = toFiniteNumber(row.personnelCount)
const workDay = toFiniteNumber(row.workdayCount)
const fee = toFiniteNumber(row.serviceBudget)
const desc = typeof row.remark === 'string' ? row.remark : ''
const hasValue = personNum != null || workDay != null || fee != null || isNonEmptyString(desc)
if (!hasValue) return null
return {
expert,
price: toFiniteNumberOrZero(row.adoptedBudgetUnitPrice),
person_num: personNum ?? 0,
work_day: workDay ?? 0,
fee: fee ?? 0,
desc
}
})
.filter((item): item is ExportMethod4Detail => Boolean(item))
if (det.length === 0) return null
return {
person_num: sumNumbers(det.map(item => item.person_num)),
work_day: sumNumbers(det.map(item => item.work_day)),
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildServiceFee = (
row: ZxFwRowLike,
method1: ExportMethod1 | null,
method2: ExportMethod2 | null,
method3: ExportMethod3 | null,
method4: ExportMethod4 | null
) => {
const subtotal = toFiniteNumber(row.subtotal)
if (subtotal != null) return subtotal
const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
if (methodSum !== 0) return methodSum
return sumNumbers([
toFiniteNumber(row.investScale),
toFiniteNumber(row.landScale),
toFiniteNumber(row.workload),
toFiniteNumber(row.hourly)
])
}
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const [xmInfoRaw, contractCardsRaw] = await Promise.all([
localforage.getItem<XmInfoStorageLike>('xm-info-v3'),
localforage.getItem<ContractCardItem[]>('ht-card-v1')
])
const xmInfo = xmInfoRaw || {}
const projectScale = buildScaleRows(xmInfo.detailRows)
const projectName = isNonEmptyString(xmInfo.projectName) ? xmInfo.projectName.trim() : '造价项目'
const contractCards = (Array.isArray(contractCardsRaw) ? contractCardsRaw : [])
.filter(item => item && typeof item.id === 'string')
.sort((a, b) => (typeof a.order === 'number' ? a.order : Number.MAX_SAFE_INTEGER) - (typeof b.order === 'number' ? b.order : Number.MAX_SAFE_INTEGER))
const contracts: ExportContract[] = []
for (let index = 0; index < contractCards.length; index++) {
const contract = contractCards[index]
const contractId = contract.id
const [htInfoRaw, zxFwRaw] = await Promise.all([
localforage.getItem<DetailRowsStorageLike<ScaleRowLike>>(`ht-info-v3-${contractId}`),
localforage.getItem<ZxFwStorageLike>(`zxFW-${contractId}`)
])
const zxRows = Array.isArray(zxFwRaw?.detailRows) ? zxFwRaw.detailRows : []
const fixedRow = zxRows.find(row => row.id === 'fixed-budget-c')
const serviceRows = zxRows.filter(row => row.id !== 'fixed-budget-c' && hasServiceId(String(row.id)))
const services = (
await Promise.all(
serviceRows.map(async row => {
const serviceIdText = String(row.id)
const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null
const [method1Raw, method2Raw, method3Raw, method4Raw] = await Promise.all([
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`tzGMF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<ScaleMethodRowLike>>(`ydGMF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<WorkloadMethodRowLike>>(`gzlF-${contractId}-${serviceIdText}`),
localforage.getItem<DetailRowsStorageLike<HourlyMethodRowLike>>(`hourlyPricing-${contractId}-${serviceIdText}`)
])
const method1 = buildMethod1(method1Raw?.detailRows)
const method2 = buildMethod2(method2Raw?.detailRows)
const method3 = buildMethod3(method3Raw?.detailRows)
const method4 = buildMethod4(method4Raw?.detailRows)
const fee = buildServiceFee(row, method1, method2, method3, method4)
const service: ExportService = {
id: serviceId,
fee
}
if (method1) service.method1 = method1
if (method2) service.method2 = method2
if (method3) service.method3 = method3
if (method4) service.method4 = method4
return service
})
)
).filter((item): item is ExportService => Boolean(item))
const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal)
const serviceFeeSum = sumNumbers(services.map(item => item.fee))
const fixedMethodSum = sumNumbers([
toFiniteNumber(fixedRow?.investScale),
toFiniteNumber(fixedRow?.landScale),
toFiniteNumber(fixedRow?.workload),
toFiniteNumber(fixedRow?.hourly)
])
const contractFee = fixedSubtotal ?? (serviceFeeSum !== 0 ? serviceFeeSum : fixedMethodSum)
contracts.push({
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`,
fee: contractFee,
scale: buildScaleRows(htInfoRaw?.detailRows),
services
})
}
return {
name: projectName,
fee: sumNumbers(contracts.map(item => item.fee)),
scale: projectScale,
contracts
}
}
const exportData = async () => {
try {
const now = new Date()
@@ -485,8 +971,19 @@ const exportData = async () => {
dataMenuOpen.value = false
}
}
const exportReport = async ()=>{
const exportReport = async () => {
try {
const now = new Date()
const payload = await buildExportReportPayload()
const fileName = `${sanitizeFileNamePart(payload.name)}-报表-${formatExportTimestamp(now)}`
await exportFile(fileName, payload)
} catch (error) {
console.error('export report failed:', error)
window.alert('导出报表失败,请重试。')
} finally {
dataMenuOpen.value = false
}
}
const triggerImport = () => {
importFileRef.value?.click()
+20 -3
View File
@@ -11,7 +11,6 @@ import {
DialogTitle,
DialogTrigger,DialogDescription
} from 'reka-ui'
import { Icon } from '@iconify/vue'
import { useWindowSize } from '@vueuse/core'
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
interface TypeLineCategory {
@@ -285,7 +284,16 @@ useMotionValueEvent(
<div class="mb-3">
<div class="flex justify-end">
<DialogClose class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
<Icon icon="lucide:x" class="h-4 w-4" />
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 6L6 18M6 6l12 12"
/>
</svg>
</DialogClose>
</div>
<DialogTitle class="mt-2">
@@ -314,7 +322,16 @@ useMotionValueEvent(
aria-label="跳转到官网首页"
title="官网首页"
>
<Icon icon="lucide:arrow-up-right" class="h-4 w-4" />
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h10v10M7 17L17 7"
/>
</svg>
</a>
</div>
</DialogDescription>