1
This commit is contained in:
parent
f79e8e0da6
commit
2a2c0fe2d7
@ -190,6 +190,7 @@ let data1 = {
|
|||||||
name: 'test001',
|
name: 'test001',
|
||||||
writer: '张三',// 编制人
|
writer: '张三',// 编制人
|
||||||
reviewer: '李四',// 复核人
|
reviewer: '李四',// 复核人
|
||||||
|
company: '测试公司',// 公司名称
|
||||||
date: '2021-09-24',// 编制日期
|
date: '2021-09-24',// 编制日期
|
||||||
industry: 0,// 0为公路工程,1为铁路工程,2为水运工程
|
industry: 0,// 0为公路工程,1为铁路工程,2为水运工程
|
||||||
fee: 10000,
|
fee: 10000,
|
||||||
@ -284,8 +285,10 @@ let data1 = {
|
|||||||
basicFee_basic: 200,
|
basicFee_basic: 200,
|
||||||
basicFee_optional: 0,
|
basicFee_optional: 0,
|
||||||
fee: 250000,
|
fee: 250000,
|
||||||
|
proAmount: 3,
|
||||||
det: [
|
det: [
|
||||||
{
|
{
|
||||||
|
proNum: 1,
|
||||||
major: 0,
|
major: 0,
|
||||||
cost: 100000,
|
cost: 100000,
|
||||||
basicFee: 200,
|
basicFee: 200,
|
||||||
@ -308,8 +311,10 @@ let data1 = {
|
|||||||
basicFee_basic: 200,
|
basicFee_basic: 200,
|
||||||
basicFee_optional: 0,
|
basicFee_optional: 0,
|
||||||
fee: 250000,
|
fee: 250000,
|
||||||
|
proAmount: 3,
|
||||||
det: [
|
det: [
|
||||||
{
|
{
|
||||||
|
proNum: 1,
|
||||||
major: 0,
|
major: 0,
|
||||||
area: 1200,
|
area: 1200,
|
||||||
basicFee: 200,
|
basicFee: 200,
|
||||||
@ -375,14 +380,128 @@ let data1 = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
addtional: [// 附加工作费
|
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' }] },
|
||||||
type: 0,// 0为费率计取,1为工时法,2为数量单价
|
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,
|
coe: 0.03,
|
||||||
fee: 10000,
|
fee: 10000,
|
||||||
},
|
},
|
||||||
{
|
m4: {
|
||||||
type: 1,// 0为费率计取,1为工时法,2为数量单价
|
person_num: 10,
|
||||||
|
work_day: 3,
|
||||||
fee: 10000,
|
fee: 10000,
|
||||||
det: [
|
det: [
|
||||||
{
|
{
|
||||||
@ -403,8 +522,7 @@ let data1 = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
m5: {
|
||||||
type: 2,// 0为费率计取,1为工时法,2为数量单价
|
|
||||||
fee: 10000,
|
fee: 10000,
|
||||||
det: [
|
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 = {
|
let data2 = {
|
||||||
name: 'test001',
|
name: 'test001',
|
||||||
scale: [
|
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
|
<ScrollAreaViewport
|
||||||
data-slot="scroll-area-viewport"
|
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 />
|
<slot />
|
||||||
</ScrollAreaViewport>
|
</ScrollAreaViewport>
|
||||||
@ -31,3 +31,9 @@ const delegatedProps = reactiveOmit(props, "class")
|
|||||||
<ScrollAreaCorner />
|
<ScrollAreaCorner />
|
||||||
</ScrollAreaRoot>
|
</ScrollAreaRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.scrollArea-full > *) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,14 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
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<{
|
const props = defineProps<{
|
||||||
contractId: string
|
contractId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
|
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
|
||||||
|
const additionalWorkNames = computed(() => additionalWorkList.map(item => String(item)))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HtFeeGrid title="附加工作费" :storageKey="STORAGE_KEY" />
|
<HtFeeMethodGrid
|
||||||
|
title="附加工作费"
|
||||||
|
:storageKey="STORAGE_KEY"
|
||||||
|
:readonly="true"
|
||||||
|
:fixed-names="additionalWorkNames"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,10 +1,55 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contractId: string
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -12,7 +57,7 @@ const props = defineProps<{
|
|||||||
title="咨询分类系数明细"
|
title="咨询分类系数明细"
|
||||||
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
|
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
|
||||||
parent-storage-key="xm-consult-category-factor-v1"
|
parent-storage-key="xm-consult-category-factor-v1"
|
||||||
:dict="serviceList"
|
:dict="filteredServiceDict"
|
||||||
:disable-budget-edit-when-standard-null="true"
|
:disable-budget-edit-when-standard-null="true"
|
||||||
:exclude-notshow-by-zxflxs="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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
|
import HtFeeMethodGrid from '@/components/common/HtFeeMethodGrid.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contractId: string
|
contractId: string
|
||||||
@ -10,5 +10,5 @@ const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HtFeeGrid title="预备费" :storageKey="STORAGE_KEY" />
|
<HtFeeMethodGrid title="预备费" :storageKey="STORAGE_KEY" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -139,7 +139,7 @@ const serviceDict = computed<ServiceItem[]>(() => {
|
|||||||
const serviceById = computed(() => new Map(serviceDict.value.map(item => [item.id, item])))
|
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 serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id])))
|
||||||
const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|'))
|
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 isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
|
||||||
|
|
||||||
const selectedIds = ref<string[]>([])
|
const selectedIds = ref<string[]>([])
|
||||||
|
|||||||
@ -344,6 +344,7 @@ const componentMap: Record<string, any> = {
|
|||||||
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/xmCard.vue'))),
|
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/xmCard.vue'))),
|
||||||
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/htCard.vue'))),
|
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/htCard.vue'))),
|
||||||
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
|
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
|
||||||
|
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/components/views/HtFeeMethodTypeLineView.vue'))),
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
@ -1060,7 +1061,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
|||||||
localforage.getItem<ContractCardItem[]>('ht-card-v1')
|
localforage.getItem<ContractCardItem[]>('ht-card-v1')
|
||||||
])
|
])
|
||||||
|
|
||||||
const projectInfo = projectInfoRaw || {}
|
const projectInfo = projectInfoRaw || {}
|
||||||
const projectScaleSource = projectScaleRaw || {}
|
const projectScaleSource = projectScaleRaw || {}
|
||||||
const projectScale = buildScaleRows(projectScaleSource.detailRows)
|
const projectScale = buildScaleRows(projectScaleSource.detailRows)
|
||||||
const projectScaleCost = sumNumbers(projectScale.map(item => item.cost))
|
const projectScaleCost = sumNumbers(projectScale.map(item => item.cost))
|
||||||
@ -1349,291 +1350,220 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
<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="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
|
<div class="flex min-w-0 items-start gap-1 h-full" @mouseenter="isTabStripHover = true"
|
||||||
class="flex min-w-0 flex-1 items-start gap-1 h-full self-start"
|
@mouseleave="isTabStripHover = false">
|
||||||
@mouseenter="isTabStripHover = true"
|
<button type="button" :class="[
|
||||||
@mouseleave="isTabStripHover = false"
|
'h-9 w-8 self-center shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||||||
>
|
|
||||||
<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',
|
|
||||||
isTabStripHover && showTabScrollLeft ? 'opacity-100' : 'pointer-events-none opacity-0'
|
isTabStripHover && showTabScrollLeft ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
]"
|
]" @click="scrollTabStripBy(-260)">
|
||||||
@click="scrollTabStripBy(-260)"
|
<
|
||||||
>
|
</button>
|
||||||
<
|
<ScrollArea :ref="setTabScrollAreaRef" type="auto"
|
||||||
</button>
|
class="h-full tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
||||||
<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"
|
||||||
<draggable
|
:class="['tab-strip-sortable h-[calc(3.49rem)] flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
|
||||||
v-model="tabsModel"
|
:animation="260" easing="cubic-bezier(0.22, 1, 0.36, 1)" ghost-class="tab-drag-ghost"
|
||||||
item-key="id"
|
chosen-class="tab-drag-chosen" drag-class="tab-drag-active" :move="canMoveTab" @start="handleTabDragStart"
|
||||||
tag="div"
|
@end="handleTabDragEnd">
|
||||||
:class="['tab-strip-sortable flex w-max gap-0', isTabDragging ? 'is-dragging' : '']"
|
<template #item="{ element: tab }">
|
||||||
:animation="260"
|
<div :ref="el => setTabItemRef(tab.id, el)" @mousedown.left="tabStore.activeTabId = tab.id"
|
||||||
easing="cubic-bezier(0.22, 1, 0.36, 1)"
|
@contextmenu.prevent="openTabContextMenu($event, tab.id)" :class="[
|
||||||
ghost-class="tab-drag-ghost"
|
'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',
|
||||||
chosen-class="tab-drag-chosen"
|
tabStore.activeTabId === tab.id && !isTabDragging
|
||||||
drag-class="tab-drag-active"
|
? 'z-10 bg-background text-foreground !border-border !border-b-0 font-medium'
|
||||||
:move="canMoveTab"
|
: 'bg-muted/25 text-muted-foreground hover:bg-muted/40 hover:text-foreground hover:border-border/70',
|
||||||
@start="handleTabDragStart"
|
tab.id !== 'XmView' ? 'cursor-move' : ''
|
||||||
@end="handleTabDragEnd"
|
]">
|
||||||
>
|
<TooltipRoot>
|
||||||
<template #item="{ element: tab }">
|
<TooltipTrigger as-child>
|
||||||
<div
|
<span :ref="el => setTabTitleRef(tab.id, el)" class="truncate mr-2">
|
||||||
:ref="el => setTabItemRef(tab.id, el)"
|
{{ tab.title }}
|
||||||
@mousedown.left="tabStore.activeTabId = tab.id"
|
</span>
|
||||||
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
|
</TooltipTrigger>
|
||||||
:class="[
|
<TooltipContent v-if="tabTitleOverflowMap[tab.id]" side="bottom">{{ tab.title }}</TooltipContent>
|
||||||
'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',
|
</TooltipRoot>
|
||||||
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
|
<Button v-if="tab.id !== 'XmView'" variant="ghost" size="icon"
|
||||||
v-if="tab.id !== 'XmView'"
|
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
||||||
variant="ghost"
|
@click.stop="tabStore.removeTab(tab.id)">
|
||||||
size="icon"
|
<X class="h-3 w-3" />
|
||||||
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
</Button>
|
||||||
@click.stop="tabStore.removeTab(tab.id)"
|
</div>
|
||||||
>
|
</template>
|
||||||
<X class="h-3 w-3" />
|
</draggable>
|
||||||
</Button>
|
</ScrollArea>
|
||||||
</div>
|
<button type="button" :class="[
|
||||||
</template>
|
' self-center h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted ',
|
||||||
</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',
|
|
||||||
isTabStripHover && showTabScrollRight ? 'opacity-100' : 'pointer-events-none opacity-0'
|
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>
|
</button>
|
||||||
</div>
|
</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
|
<div v-if="userGuideOpen" class="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 p-4"
|
||||||
variant="outline"
|
@click.self="closeUserGuide(false)">
|
||||||
size="sm"
|
<div class="w-full max-w-3xl rounded-xl border bg-background shadow-2xl">
|
||||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
<div class="flex items-start justify-between border-b px-6 py-5">
|
||||||
@click="openUserGuide(0)"
|
<div>
|
||||||
>
|
<p class="text-xs text-muted-foreground">新用户引导 · {{ guideProgressText }}</p>
|
||||||
<CircleHelp class="h-4 w-4 mr-1" />
|
<h3 class="mt-1 text-lg font-semibold">{{ activeGuideStep.title }}</h3>
|
||||||
使用引导
|
</div>
|
||||||
</Button>
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeUserGuide(false)">
|
||||||
|
<X class="h-4 w-4" />
|
||||||
<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>
|
</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>
|
</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">
|
<div class="space-y-4 px-6 py-5">
|
||||||
<p class="text-sm leading-6 text-foreground">{{ activeGuideStep.description }}</p>
|
<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">
|
<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}`">
|
<li v-for="(point, index) in activeGuideStep.points" :key="`${activeGuideStep.title}-${index}`">
|
||||||
{{ point }}
|
{{ point }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
v-for="(_step, index) in userGuideSteps"
|
|
||||||
:key="`guide-dot-${index}`"
|
|
||||||
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
|
|
||||||
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'"
|
|
||||||
:aria-label="`跳转到第 ${index + 1} 步`"
|
|
||||||
@click="jumpToGuideStep(index)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<Button variant="ghost" @click="closeUserGuide(false)">稍后再看</Button>
|
<div class="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Button variant="outline" :disabled="isFirstGuideStep" @click="prevUserGuideStep">上一步</Button>
|
<div class="flex items-center gap-1.5">
|
||||||
<Button @click="nextUserGuideStep">{{ isLastGuideStep ? '完成并不再自动弹出' : '下一步' }}</Button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tab-strip-sortable > .tab-item {
|
.tab-strip-sortable>.tab-item {
|
||||||
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
|
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;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1652,9 +1582,15 @@ watch(
|
|||||||
|
|
||||||
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
|
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
overflow-y: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]::-webkit-scrollbar) {
|
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]::-webkit-scrollbar) {
|
||||||
display: none;
|
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>
|
||||||
|
|||||||
@ -151,6 +151,9 @@ export const expertList = {
|
|||||||
7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
|
7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const additionalWorkList
|
||||||
|
=['人员驻场服务及其他附加工作','咨询服务协调工作']
|
||||||
|
|
||||||
let costScaleCal = [
|
let costScaleCal = [
|
||||||
{ code: 'C1-1', staLine: 0, endLine: 100, basic: { staPrice: 0, rate: 0.01 }, optional: { staPrice: 0, rate: 0.002 } },
|
{ 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 } },
|
{ 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