1
This commit is contained in:
parent
f79e8e0da6
commit
2a2c0fe2d7
@ -190,6 +190,7 @@ let data1 = {
|
||||
name: 'test001',
|
||||
writer: '张三',// 编制人
|
||||
reviewer: '李四',// 复核人
|
||||
company: '测试公司',// 公司名称
|
||||
date: '2021-09-24',// 编制日期
|
||||
industry: 0,// 0为公路工程,1为铁路工程,2为水运工程
|
||||
fee: 10000,
|
||||
@ -284,8 +285,10 @@ let data1 = {
|
||||
basicFee_basic: 200,
|
||||
basicFee_optional: 0,
|
||||
fee: 250000,
|
||||
proAmount: 3,
|
||||
det: [
|
||||
{
|
||||
proNum: 1,
|
||||
major: 0,
|
||||
cost: 100000,
|
||||
basicFee: 200,
|
||||
@ -308,8 +311,10 @@ let data1 = {
|
||||
basicFee_basic: 200,
|
||||
basicFee_optional: 0,
|
||||
fee: 250000,
|
||||
proAmount: 3,
|
||||
det: [
|
||||
{
|
||||
proNum: 1,
|
||||
major: 0,
|
||||
area: 1200,
|
||||
basicFee: 200,
|
||||
@ -375,14 +380,128 @@ let data1 = {
|
||||
},
|
||||
},
|
||||
],
|
||||
addtional: [// 附加工作费
|
||||
{
|
||||
type: 0,// 0为费率计取,1为工时法,2为数量单价
|
||||
addtional: {// 附加工作费
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
|
||||
name: '附加工作',
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
id: 0,
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
|
||||
name: '人员驻场服务及其他附加工作',
|
||||
fee: 10000,
|
||||
m4: { //工时
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
expert: 0,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
expert: 1,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
m5: { //数量单价
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' }] },
|
||||
name: '咨询服务协调工作',
|
||||
fee: 10000,
|
||||
m0: {
|
||||
coe: 0.03,
|
||||
fee: 10000,
|
||||
},
|
||||
m4: {
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
expert: 0,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
expert: 1,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
m5: {
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
reserve: {// 预备费
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }] },
|
||||
name: '预备费',
|
||||
fee: 10000,
|
||||
m0: {
|
||||
coe: 0.03,
|
||||
fee: 10000,
|
||||
},
|
||||
{
|
||||
type: 1,// 0为费率计取,1为工时法,2为数量单价
|
||||
m4: {
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
@ -403,8 +522,7 @@ let data1 = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 2,// 0为费率计取,1为工时法,2为数量单价
|
||||
m5: {
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
@ -425,61 +543,11 @@ let data1 = {
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
reserve: [// 预留费
|
||||
{
|
||||
type: 0,// 0为费率计取,1为工时法,2为数量单价
|
||||
coe: 0.03,
|
||||
fee: 10000,
|
||||
},
|
||||
{
|
||||
type: 1,// 0为费率计取,1为工时法,2为数量单价
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
expert: 0,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
expert: 1,
|
||||
price: 100000,
|
||||
person_num: 10,
|
||||
work_day: 3,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 2,// 0为费率计取,1为工时法,2为数量单价
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
{
|
||||
name: '×××项',
|
||||
unit: '项',
|
||||
amount: 10,
|
||||
price: 100000,
|
||||
fee: 100000,
|
||||
remark: '',// 用户输入的说明
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let data2 = {
|
||||
name: 'test001',
|
||||
scale: [
|
||||
|
||||
460
src/components/common/HtFeeMethodGrid.vue
Normal file
460
src/components/common/HtFeeMethodGrid.vue
Normal file
@ -0,0 +1,460 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import localforage from 'localforage'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { Pencil, Eraser } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
|
||||
interface FeeMethodRow {
|
||||
id: string
|
||||
name: string
|
||||
rateFee: number | null
|
||||
hourlyFee: number | null
|
||||
quantityUnitPriceFee: number | null
|
||||
subtotal?: number | null
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
interface FeeMethodState {
|
||||
detailRows: FeeMethodRow[]
|
||||
}
|
||||
|
||||
interface LegacyFeeRow {
|
||||
id?: string
|
||||
feeItem?: string
|
||||
budgetFee?: number | null
|
||||
quantity?: number | null
|
||||
unitPrice?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
readonly?: boolean
|
||||
fixedNames?: string[]
|
||||
}>()
|
||||
const tabStore = useTabStore()
|
||||
|
||||
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
||||
id: createRowId(),
|
||||
name,
|
||||
rateFee: null,
|
||||
hourlyFee: null,
|
||||
quantityUnitPriceFee: null
|
||||
})
|
||||
const SUMMARY_ROW_ID = 'fee-method-summary'
|
||||
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
|
||||
const toFinite = (value: number | null | undefined) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
const round3 = (value: number) => Number(value.toFixed(3))
|
||||
const getRowSubtotal = (row: FeeMethodRow | null | undefined) =>
|
||||
row ? round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee)) : null
|
||||
|
||||
const isReadonly = computed(() => props.readonly === true)
|
||||
const fixedNames = computed(() =>
|
||||
Array.isArray(props.fixedNames)
|
||||
? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
)
|
||||
const hasFixedNames = computed(() => fixedNames.value.length > 0)
|
||||
const detailRows = ref<FeeMethodRow[]>([createDefaultRow()])
|
||||
const summaryRow = computed<FeeMethodRow>(() => {
|
||||
const totals = detailRows.value.reduce(
|
||||
(acc, row) => {
|
||||
acc.rateFee += toFinite(row.rateFee)
|
||||
acc.hourlyFee += toFinite(row.hourlyFee)
|
||||
acc.quantityUnitPriceFee += toFinite(row.quantityUnitPriceFee)
|
||||
return acc
|
||||
},
|
||||
{
|
||||
rateFee: 0,
|
||||
hourlyFee: 0,
|
||||
quantityUnitPriceFee: 0
|
||||
}
|
||||
)
|
||||
const result: FeeMethodRow = {
|
||||
id: SUMMARY_ROW_ID,
|
||||
name: '小计',
|
||||
rateFee: round3(totals.rateFee),
|
||||
hourlyFee: round3(totals.hourlyFee),
|
||||
quantityUnitPriceFee: round3(totals.quantityUnitPriceFee)
|
||||
}
|
||||
result.subtotal = getRowSubtotal(result)
|
||||
return result
|
||||
})
|
||||
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
|
||||
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
|
||||
|
||||
const formatEditableText = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (params.context?.readonly === true || isSummaryRow(params.data)) return ''
|
||||
return '点击输入'
|
||||
}
|
||||
return String(params.value)
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (params.context?.readonly === true || isSummaryRow(params.data)) return ''
|
||||
return '点击输入'
|
||||
}
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const numericParser = (newValue: any): number | null =>
|
||||
parseNumberOrNull(newValue, { precision: 3 })
|
||||
|
||||
const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
|
||||
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) return row.budgetFee
|
||||
if (
|
||||
typeof row.quantity === 'number' &&
|
||||
Number.isFinite(row.quantity) &&
|
||||
typeof row.unitPrice === 'number' &&
|
||||
Number.isFinite(row.unitPrice)
|
||||
) {
|
||||
return Number((row.quantity * row.unitPrice).toFixed(2))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
|
||||
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
|
||||
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
|
||||
)
|
||||
const rows = sourceRows.map(item => {
|
||||
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
|
||||
return {
|
||||
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
||||
name:
|
||||
typeof row.name === 'string'
|
||||
? row.name
|
||||
: (typeof row.feeItem === 'string' ? row.feeItem : ''),
|
||||
rateFee: typeof row.rateFee === 'number' ? row.rateFee : null,
|
||||
hourlyFee: typeof row.hourlyFee === 'number' ? row.hourlyFee : null,
|
||||
quantityUnitPriceFee:
|
||||
typeof row.quantityUnitPriceFee === 'number'
|
||||
? row.quantityUnitPriceFee
|
||||
: toLegacyQuantityUnitPriceFee(row)
|
||||
} as FeeMethodRow
|
||||
})
|
||||
if (hasFixedNames.value) {
|
||||
const byName = new Map(rows.map(row => [row.name, row]))
|
||||
return fixedNames.value.map((name, index) => {
|
||||
const fromDb = byName.get(name)
|
||||
return {
|
||||
id: fromDb?.id || `fee-method-fixed-${index}`,
|
||||
name,
|
||||
rateFee: fromDb?.rateFee ?? null,
|
||||
hourlyFee: fromDb?.hourlyFee ?? null,
|
||||
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
return rows.length > 0 ? rows : [createDefaultRow()]
|
||||
}
|
||||
|
||||
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: FeeMethodState = {
|
||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||
}
|
||||
await localforage.setItem(props.storageKey, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<FeeMethodState>(props.storageKey)
|
||||
detailRows.value = mergeWithStoredRows(data?.detailRows)
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = mergeWithStoredRows([])
|
||||
}
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
if (isReadonly.value) return
|
||||
detailRows.value = [...detailRows.value, createDefaultRow()]
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const clearRow = (id: string) => {
|
||||
if (isReadonly.value) return
|
||||
detailRows.value = detailRows.value.map(row =>
|
||||
row.id !== id
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
rateFee: null,
|
||||
hourlyFee: null,
|
||||
quantityUnitPriceFee: null
|
||||
}
|
||||
)
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const editRow = (id: string) => {
|
||||
if (isReadonly.value) return
|
||||
const row = detailRows.value.find(item => item.id === id)
|
||||
if (!row) return
|
||||
tabStore.openTab({
|
||||
id: `ht-fee-edit-${props.storageKey}-${id}`,
|
||||
title: `费用编辑-${row.name || '未命名'}`,
|
||||
componentName: 'HtFeeMethodTypeLineView',
|
||||
props: {
|
||||
sourceTitle: props.title,
|
||||
storageKey: props.storageKey,
|
||||
rowId: id,
|
||||
rowName: row.name || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ActionCellRenderer = defineComponent({
|
||||
name: 'HtFeeMethodActionCellRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<FeeMethodRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
|
||||
const disabled = props.params.context?.readonly === true
|
||||
return h('div', { class: 'zxfw-action-wrap' }, [
|
||||
h('div', { class: 'zxfw-action-group' }, [
|
||||
h('button', {
|
||||
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''],
|
||||
'data-action': 'edit',
|
||||
type: 'button',
|
||||
disabled
|
||||
}, [
|
||||
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', '编辑')
|
||||
]),
|
||||
h('button', {
|
||||
class: ['zxfw-action-btn', disabled ? 'zxfw-action-btn--disabled' : ''],
|
||||
'data-action': 'clear',
|
||||
type: 'button',
|
||||
disabled
|
||||
}, [
|
||||
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', '恢复默认')
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||
{
|
||||
headerName: '名字',
|
||||
field: 'name',
|
||||
minWidth: 180,
|
||||
flex: 1.8,
|
||||
editable: params =>
|
||||
!(params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data)),
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true || params.context?.fixedNames === true || isSummaryRow(params.data)
|
||||
? ''
|
||||
: 'editable-cell-line',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '费率计取',
|
||||
field: 'rateFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true
|
||||
? 'ag-right-aligned-cell'
|
||||
: 'ag-right-aligned-cell editable-cell-line',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '工时法',
|
||||
field: 'hourlyFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true
|
||||
? 'ag-right-aligned-cell'
|
||||
: 'ag-right-aligned-cell editable-cell-line',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '数量单价',
|
||||
field: 'quantityUnitPriceFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: params => params.context?.readonly !== true && !isSummaryRow(params.data),
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params =>
|
||||
params.context?.readonly === true
|
||||
? 'ag-right-aligned-cell'
|
||||
: 'ag-right-aligned-cell editable-cell-line',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '小计',
|
||||
field: 'subtotal',
|
||||
minWidth: 140,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueGetter: params => getRowSubtotal(params.data),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '操作',
|
||||
field: 'actions',
|
||||
minWidth: 220,
|
||||
flex: 1.6,
|
||||
maxWidth: 260,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: ActionCellRenderer
|
||||
}
|
||||
]
|
||||
|
||||
const detailGridOptions: GridOptions<FeeMethodRow> = {
|
||||
...gridOptions,
|
||||
treeData: false,
|
||||
getDataPath: undefined,
|
||||
context: {
|
||||
readonly: isReadonly.value,
|
||||
fixedNames: hasFixedNames.value
|
||||
},
|
||||
onCellClicked: params => {
|
||||
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
|
||||
if (params.context?.readonly === true) return
|
||||
const target = params.event?.target as HTMLElement | null
|
||||
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
||||
const action = btn?.dataset.action
|
||||
if (action === 'edit') {
|
||||
editRow(params.data.id)
|
||||
return
|
||||
}
|
||||
if (action === 'clear') {
|
||||
clearRow(params.data.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||
if (isReadonly.value) return
|
||||
if (isSummaryRow(event.data)) return
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadFromIndexedDB()
|
||||
})
|
||||
|
||||
const storageKeyRef = computed(() => props.storageKey)
|
||||
watch(storageKeyRef, () => {
|
||||
void loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch([isReadonly, hasFixedNames], () => {
|
||||
if (!detailGridOptions.context) return
|
||||
detailGridOptions.context.readonly = isReadonly.value
|
||||
detailGridOptions.context.fixedNames = hasFixedNames.value
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<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">{{ title }}</h3>
|
||||
<Button v-if="!isReadonly" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="displayRows"
|
||||
:columnDefs="columnDefs"
|
||||
:gridOptions="detailGridOptions"
|
||||
:theme="myTheme"
|
||||
:treeData="false"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zxfw-action-btn--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@ -23,7 +23,7 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
>
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 scrollArea-full"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
@ -31,3 +31,9 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.scrollArea-full > *) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
|
||||
import HtFeeMethodGrid from '@/components/common/HtFeeMethodGrid.vue'
|
||||
import { additionalWorkList } from '@/sql'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
}>()
|
||||
|
||||
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
|
||||
const additionalWorkNames = computed(() => additionalWorkList.map(item => String(item)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HtFeeGrid title="附加工作费" :storageKey="STORAGE_KEY" />
|
||||
<HtFeeMethodGrid
|
||||
title="附加工作费"
|
||||
:storageKey="STORAGE_KEY"
|
||||
:readonly="true"
|
||||
:fixed-names="additionalWorkNames"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,10 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { serviceList } from '@/sql'
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import localforage from 'localforage'
|
||||
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
|
||||
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
}>()
|
||||
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
type ServiceItem = {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const projectIndustry = ref('')
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await localforage.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
console.error('loadProjectIndustry failed:', error)
|
||||
projectIndustry.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const filteredServiceDict = computed<Record<string, ServiceItem>>(() => {
|
||||
const industry = projectIndustry.value
|
||||
if (!industry) return {}
|
||||
const entries = getServiceDictEntries()
|
||||
.filter(({ item }) => isIndustryEnabledByType(item, getIndustryTypeValue(industry)))
|
||||
.map(({ id, item }) => [id, item as ServiceItem] as const)
|
||||
return Object.fromEntries(entries)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadProjectIndustry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -12,7 +57,7 @@ const props = defineProps<{
|
||||
title="咨询分类系数明细"
|
||||
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
|
||||
parent-storage-key="xm-consult-category-factor-v1"
|
||||
:dict="serviceList"
|
||||
:dict="filteredServiceDict"
|
||||
:disable-budget-edit-when-standard-null="true"
|
||||
:exclude-notshow-by-zxflxs="true"
|
||||
/>
|
||||
|
||||
55
src/components/views/HtFeeMethodTypeLineView.vue
Normal file
55
src/components/views/HtFeeMethodTypeLineView.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<TypeLine
|
||||
scene="ht-fee-method-type-line"
|
||||
:title="`${sourceTitleText}:${rowNameText}`"
|
||||
:subtitle="`明细ID:${props.rowId}`"
|
||||
:copy-text="props.rowId"
|
||||
:storage-key="activeTypeStorageKey"
|
||||
default-category="rate-fee"
|
||||
:categories="categories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, markRaw, type Component } from 'vue'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
|
||||
interface TypeLineCategoryItem {
|
||||
key: string
|
||||
label: string
|
||||
component: Component
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sourceTitle?: string
|
||||
storageKey: string
|
||||
rowId: string
|
||||
rowName?: string
|
||||
}>()
|
||||
|
||||
const sourceTitleText = computed(() => props.sourceTitle || '费用明细')
|
||||
const rowNameText = computed(() => props.rowName || '未命名')
|
||||
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${props.rowId}`)
|
||||
|
||||
const createMethodPane = (methodLabel: string, key: string) =>
|
||||
markRaw(
|
||||
defineComponent({
|
||||
name: `HtFeeMethodTypePane-${key}`,
|
||||
setup() {
|
||||
return () =>
|
||||
h('div', { class: 'h-full min-h-0 flex flex-col' }, [
|
||||
h('div', { class: 'rounded-lg border bg-card p-4' }, [
|
||||
h('h3', { class: 'text-sm font-semibold text-foreground' }, methodLabel),
|
||||
h('p', { class: 'mt-2 text-xs text-muted-foreground' }, `当前编辑类型:${methodLabel}`)
|
||||
])
|
||||
])
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const categories: TypeLineCategoryItem[] = [
|
||||
{ key: 'rate-fee', label: '费率计取', component: createMethodPane('费率计取', 'rate-fee') },
|
||||
{ key: 'hourly-fee', label: '工时法', component: createMethodPane('工时法', 'hourly-fee') },
|
||||
{ key: 'quantity-unit-price-fee', label: '数量单价', component: createMethodPane('数量单价', 'quantity-unit-price-fee') }
|
||||
]
|
||||
</script>
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
|
||||
import HtFeeMethodGrid from '@/components/common/HtFeeMethodGrid.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
@ -10,5 +10,5 @@ const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HtFeeGrid title="预备费" :storageKey="STORAGE_KEY" />
|
||||
<HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" />
|
||||
</template>
|
||||
|
||||
@ -139,7 +139,7 @@ const serviceDict = computed<ServiceItem[]>(() => {
|
||||
const serviceById = computed(() => new Map(serviceDict.value.map(item => [item.id, item])))
|
||||
const serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id])))
|
||||
const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|'))
|
||||
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: 'C', name: '合同预算' }
|
||||
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: '', name: '小计' }
|
||||
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
|
||||
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
@ -344,6 +344,7 @@ const componentMap: Record<string, any> = {
|
||||
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/xmCard.vue'))),
|
||||
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/htCard.vue'))),
|
||||
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
|
||||
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/components/views/HtFeeMethodTypeLineView.vue'))),
|
||||
}
|
||||
|
||||
const tabStore = useTabStore()
|
||||
@ -1060,7 +1061,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
localforage.getItem<ContractCardItem[]>('ht-card-v1')
|
||||
])
|
||||
|
||||
const projectInfo = projectInfoRaw || {}
|
||||
const projectInfo = projectInfoRaw || {}
|
||||
const projectScaleSource = projectScaleRaw || {}
|
||||
const projectScale = buildScaleRows(projectScaleSource.detailRows)
|
||||
const projectScaleCost = sumNumbers(projectScale.map(item => item.cost))
|
||||
@ -1349,291 +1350,220 @@ watch(
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||||
<div class="flex items-start gap-2 border-b bg-muted/30 px-2 pt-1 min-h-14 flex-none">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-start gap-1 h-full self-start"
|
||||
@mouseenter="isTabStripHover = true"
|
||||
@mouseleave="isTabStripHover = false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2 border-b bg-muted/30 px-2 pt-1 h-15 flex-none">
|
||||
<div class="flex min-w-0 items-start gap-1 h-full" @mouseenter="isTabStripHover = true"
|
||||
@mouseleave="isTabStripHover = false">
|
||||
<button type="button" :class="[
|
||||
'h-9 w-8 self-center shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||||
isTabStripHover && showTabScrollLeft ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
]"
|
||||
@click="scrollTabStripBy(-260)"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<ScrollArea :ref="setTabScrollAreaRef" type="auto" class="tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
||||
<draggable
|
||||
v-model="tabsModel"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:class="['tab-strip-sortable flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
|
||||
:animation="260"
|
||||
easing="cubic-bezier(0.22, 1, 0.36, 1)"
|
||||
ghost-class="tab-drag-ghost"
|
||||
chosen-class="tab-drag-chosen"
|
||||
drag-class="tab-drag-active"
|
||||
:move="canMoveTab"
|
||||
@start="handleTabDragStart"
|
||||
@end="handleTabDragEnd"
|
||||
>
|
||||
<template #item="{ element: tab }">
|
||||
<div
|
||||
:ref="el => setTabItemRef(tab.id, el)"
|
||||
@mousedown.left="tabStore.activeTabId = tab.id"
|
||||
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
|
||||
:class="[
|
||||
'tab-item group relative -mb-px -ml-px first:ml-0 flex items-center h-10 px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border border-transparent transition-[background-color,border-color,color,box-shadow,transform] duration-200 text-sm',
|
||||
tabStore.activeTabId === tab.id && !isTabDragging
|
||||
? 'z-10 bg-background text-foreground !border-border !border-b-0 font-medium'
|
||||
: 'bg-muted/25 text-muted-foreground hover:bg-muted/40 hover:text-foreground hover:border-border/70',
|
||||
tab.id !== 'XmView' ? 'cursor-move' : ''
|
||||
]"
|
||||
>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
:ref="el => setTabTitleRef(tab.id, el)"
|
||||
class="truncate mr-2"
|
||||
>
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent v-if="tabTitleOverflowMap[tab.id]" side="bottom">{{ tab.title }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
]" @click="scrollTabStripBy(-260)">
|
||||
<
|
||||
</button>
|
||||
<ScrollArea :ref="setTabScrollAreaRef" type="auto"
|
||||
class="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
||||
<draggable v-model="tabsModel" item-key="id" tag="div"
|
||||
:class="['tab-strip-sortable h-[calc(3.49rem)] flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
|
||||
:animation="260" easing="cubic-bezier(0.22, 1, 0.36, 1)" ghost-class="tab-drag-ghost"
|
||||
chosen-class="tab-drag-chosen" drag-class="tab-drag-active" :move="canMoveTab" @start="handleTabDragStart"
|
||||
@end="handleTabDragEnd">
|
||||
<template #item="{ element: tab }">
|
||||
<div :ref="el => setTabItemRef(tab.id, el)" @mousedown.left="tabStore.activeTabId = tab.id"
|
||||
@contextmenu.prevent="openTabContextMenu($event, tab.id)" :class="[
|
||||
'tab-item group relative -mb-px -ml-px first:ml-0 flex items-center h-full px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border border-transparent transition-[background-color,border-color,color,box-shadow,transform] duration-200 text-sm',
|
||||
tabStore.activeTabId === tab.id && !isTabDragging
|
||||
? 'z-10 bg-background text-foreground !border-border !border-b-0 font-medium'
|
||||
: 'bg-muted/25 text-muted-foreground hover:bg-muted/40 hover:text-foreground hover:border-border/70',
|
||||
tab.id !== 'XmView' ? 'cursor-move' : ''
|
||||
]">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<span :ref="el => setTabTitleRef(tab.id, el)" class="truncate mr-2">
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent v-if="tabTitleOverflowMap[tab.id]" side="bottom">{{ tab.title }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
|
||||
<Button
|
||||
v-if="tab.id !== 'XmView'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
||||
@click.stop="tabStore.removeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</ScrollArea>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||||
<Button v-if="tab.id !== 'XmView'" variant="ghost" size="icon"
|
||||
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
||||
@click.stop="tabStore.removeTab(tab.id)">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</ScrollArea>
|
||||
<button type="button" :class="[
|
||||
' self-center h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted ',
|
||||
isTabStripHover && showTabScrollRight ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
]"
|
||||
@click="scrollTabStripBy(260)"
|
||||
>
|
||||
>
|
||||
]" @click="scrollTabStripBy(260)">
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 self-center items-center gap-1">
|
||||
<div ref="dataMenuRef" class="relative shrink-0">
|
||||
<Button variant="outline" size="sm"
|
||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="dataMenuOpen = !dataMenuOpen">
|
||||
<ChevronDown class="h-4 w-4 mr-1" />
|
||||
导入/导出
|
||||
</Button>
|
||||
<div v-if="dataMenuOpen"
|
||||
class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md">
|
||||
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
@click="triggerImport">
|
||||
导入
|
||||
</button>
|
||||
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
@click="exportData">
|
||||
导出
|
||||
</button>
|
||||
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
@click="exportReport">
|
||||
导出报表
|
||||
</button>
|
||||
</div>
|
||||
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="openUserGuide(0)">
|
||||
<CircleHelp class="h-4 w-4 mr-1" />
|
||||
使用引导
|
||||
</Button>
|
||||
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" size="sm"
|
||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer">
|
||||
<RotateCcw class="h-4 w-4 mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||
<AlertDialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||
将清空所有项目数据,并恢复默认页面,确认继续吗?
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="handleReset">确认重置</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
|
||||
<AlertDialogRoot :open="importConfirmOpen" @update:open="importConfirmOpen = $event">
|
||||
<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">
|
||||
将使用“{{ pendingImportFileName || '所选文件' }}”覆盖当前本地全部数据,是否继续?
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline" @click="cancelImportConfirm">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto relative">
|
||||
<div v-for="tab in tabStore.tabs" :key="tab.id" :ref="el => setTabPanelRef(tab.id, el)"
|
||||
v-show="tabStore.activeTabId === tab.id" class="h-full w-full p-4 animate-in fade-in duration-300">
|
||||
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tabContextOpen" ref="tabContextRef"
|
||||
class="fixed z-[70] w-max rounded-lg border border-border/80 bg-background/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur-sm"
|
||||
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }">
|
||||
<button
|
||||
class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasClosableTabs" @click="runTabMenuAction('all')">
|
||||
删除所有
|
||||
</button>
|
||||
<button
|
||||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!canCloseLeft" @click="runTabMenuAction('left')">
|
||||
删除左侧
|
||||
</button>
|
||||
<button
|
||||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!canCloseRight" @click="runTabMenuAction('right')">
|
||||
删除右侧
|
||||
</button>
|
||||
<button
|
||||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!canCloseOther" @click="runTabMenuAction('other')">
|
||||
删除其他
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 self-start items-start gap-1 mb-1">
|
||||
<div ref="dataMenuRef" class="relative shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="dataMenuOpen = !dataMenuOpen"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 mr-1" />
|
||||
导入/导出
|
||||
</Button>
|
||||
<div
|
||||
v-if="dataMenuOpen"
|
||||
class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md"
|
||||
>
|
||||
<button
|
||||
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
@click="triggerImport"
|
||||
>
|
||||
导入
|
||||
</button>
|
||||
<button
|
||||
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
@click="exportData"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
@click="exportReport"
|
||||
>
|
||||
导出报表
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="importFileRef"
|
||||
type="file"
|
||||
accept=".zw"
|
||||
class="hidden"
|
||||
@change="importData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="openUserGuide(0)"
|
||||
>
|
||||
<CircleHelp class="h-4 w-4 mr-1" />
|
||||
使用引导
|
||||
</Button>
|
||||
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" size="sm" class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer">
|
||||
<RotateCcw class="h-4 w-4 mr-1" />
|
||||
重置
|
||||
<div v-if="userGuideOpen" class="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="closeUserGuide(false)">
|
||||
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
|
||||
<div class="flex items-start justify-between border-b px-6 py-5">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</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 variant="destructive" @click="handleReset">确认重置</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
|
||||
<AlertDialogRoot :open="importConfirmOpen" @update:open="importConfirmOpen = $event">
|
||||
<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">
|
||||
将使用“{{ pendingImportFileName || '所选文件' }}”覆盖当前本地全部数据,是否继续?
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline" @click="cancelImportConfirm">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto relative">
|
||||
<div
|
||||
v-for="tab in tabStore.tabs"
|
||||
:key="tab.id"
|
||||
:ref="el => setTabPanelRef(tab.id, el)"
|
||||
v-show="tabStore.activeTabId === tab.id"
|
||||
class="h-full w-full p-4 animate-in fade-in duration-300"
|
||||
>
|
||||
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="tabContextOpen"
|
||||
ref="tabContextRef"
|
||||
class="fixed z-[70] w-max rounded-lg border border-border/80 bg-background/95 p-1.5 shadow-xl ring-1 ring-black/5 backdrop-blur-sm"
|
||||
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }"
|
||||
>
|
||||
<button
|
||||
class="flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasClosableTabs"
|
||||
@click="runTabMenuAction('all')"
|
||||
>
|
||||
删除所有
|
||||
</button>
|
||||
<button
|
||||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!canCloseLeft"
|
||||
@click="runTabMenuAction('left')"
|
||||
>
|
||||
删除左侧
|
||||
</button>
|
||||
<button
|
||||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!canCloseRight"
|
||||
@click="runTabMenuAction('right')"
|
||||
>
|
||||
删除右侧
|
||||
</button>
|
||||
<button
|
||||
class="mt-0.5 flex cursor-pointer items-center whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!canCloseOther"
|
||||
@click="runTabMenuAction('other')"
|
||||
>
|
||||
删除其他
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userGuideOpen"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="closeUserGuide(false)"
|
||||
>
|
||||
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
|
||||
<div class="flex items-start justify-between border-b px-6 py-5">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p>
|
||||
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 px-6 py-5">
|
||||
<p class="text-sm leading-6 text-foreground">{{ activeGuideStep.description }}</p>
|
||||
<ul class="list-disc space-y-2 pl-5 text-sm text-muted-foreground">
|
||||
<li v-for="(point, index) in activeGuideStep.points" :key="`${activeGuideStep.title}-${index}`">
|
||||
{{ point }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
v-for="(_step, index) in userGuideSteps"
|
||||
:key="`guide-dot-${index}`"
|
||||
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
|
||||
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'"
|
||||
:aria-label="`跳转到第 ${index + 1} 步`"
|
||||
@click="jumpToGuideStep(index)"
|
||||
/>
|
||||
<div class="space-y-4 px-6 py-5">
|
||||
<p class="text-sm leading-6 text-foreground">{{ activeGuideStep.description }}</p>
|
||||
<ul class="list-disc space-y-2 pl-5 text-sm text-muted-foreground">
|
||||
<li v-for="(point, index) in activeGuideStep.points" :key="`${activeGuideStep.title}-${index}`">
|
||||
{{ point }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
|
||||
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
|
||||
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button v-for="(_step, index) in userGuideSteps" :key="`guide-dot-${index}`"
|
||||
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
|
||||
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'" :aria-label="`跳转到第 ${index + 1} 步`"
|
||||
@click="jumpToGuideStep(index)" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
|
||||
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
|
||||
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-strip-sortable > .tab-item {
|
||||
.tab-strip-sortable>.tab-item {
|
||||
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.tab-strip-sortable.is-dragging > .tab-item {
|
||||
.tab-strip-sortable.is-dragging>.tab-item {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@ -1652,9 +1582,15 @@ watch(
|
||||
|
||||
.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>
|
||||
|
||||
@ -151,6 +151,9 @@ export const expertList = {
|
||||
7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
|
||||
};
|
||||
|
||||
export const additionalWorkList
|
||||
=['人员驻场服务及其他附加工作','咨询服务协调工作']
|
||||
|
||||
let costScaleCal = [
|
||||
{ code: 'C1-1', staLine: 0, endLine: 100, basic: { staPrice: 0, rate: 0.01 }, optional: { staPrice: 0, rate: 0.002 } },
|
||||
{ code: 'C1-2', staLine: 100, endLine: 300, basic: { staPrice: 10000, rate: 0.008 }, optional: { staPrice: 2000, rate: 0.0016 } },
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.vue","./src/components/common/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/ht.vue","./src/components/views/htconsultcategoryfactor.vue","./src/components/views/htmajorfactor.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htcard.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xmcard.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/htfeegrid.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.vue","./src/components/common/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/ht.vue","./src/components/views/htadditionalworkfee.vue","./src/components/views/htconsultcategoryfactor.vue","./src/components/views/htmajorfactor.vue","./src/components/views/htreservefee.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htcard.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xmcard.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
Loading…
x
Reference in New Issue
Block a user