calculator2026/src/components/shared/WorkContentGrid.vue
2026-03-18 17:57:20 +08:00

419 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, defineComponent, h, onBeforeUnmount, onMounted, PropType, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type {
CellValueChangedEvent,
ColDef,
GridApi,
GridReadyEvent,
ICellRendererParams,
ValueFormatterParams
} from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
import { Button } from '@/components/ui/button'
import { myTheme, agGridStyle } from '@/lib/diyAgGridOptions'
import { workList } from '@/sql'
import { WorkType,TYPE_LABEL_MAP } from '@/sql'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { Trash2 } from 'lucide-vue-next'
interface WorkContentRow {
id: string
content: string
type: WorkType
remark: string
checked: boolean
custom: boolean
path: string[]
}
interface WorkContentState {
detailRows: WorkContentRow[]
}
// dictMode: 'service' 按serviceId筛选workList'additional' 取serviceid=-1的数据'none' 不加载词典
const props = withDefaults(defineProps<{
title?: string
storageKey: string
serviceId?: number | string
dictMode?: 'service' | 'additional' | 'none'
}>(), {
title: '工作内容',
dictMode: 'none'
})
const emit = defineEmits<{
checkedChange: [value: string[]]
}>()
const zxFwPricingStore = useZxFwPricingStore()
const gridApi = ref<GridApi<WorkContentRow> | null>(null)
const rowData = ref<WorkContentRow[]>([])
const buildDefaultRowsFromDict = (): WorkContentRow[] => {
const rows: WorkContentRow[] = []
const entries = Object.values(workList) as Array<{ text: string; serviceid: number; order: number; type: number }>
let filtered: typeof entries
if (props.dictMode === 'service') {
const sid = Number(props.serviceId)
filtered = entries.filter(e => e.serviceid === sid)
} else if (props.dictMode === 'additional') {
filtered = entries.filter(e => e.serviceid === -1 && props.storageKey.split('-').at(-1) =='2')
} else {
return []
}
filtered.sort((a, b) => a.order - b.order)
for (const entry of filtered) {
const content = String(entry.text || '').trim()
if (!content) continue
const typeLabel = TYPE_LABEL_MAP[entry.type] ?? '基本工作'
rows.push({
id: `dict-${entry.order}`,
content,
type: typeLabel,
remark: '',
checked: false,
custom: false,
path: [typeLabel, content]
})
}
return rows
}
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
deleteConfirmOpen.value = true
}
const checkedIds = computed(() =>
rowData.value.filter(item => item.checked).map(item => item.id)
)
// 导出用:自定义内容全部包含,默认词典内容只含勾选的
const selectedTexts = computed(() =>
rowData.value
.filter(item => item.custom || item.checked)
.map(item => item.content)
.filter(Boolean)
)
defineExpose({ selectedTexts })
const emitCheckedChange = () => {
emit('checkedChange', [...checkedIds.value])
}
const saveToStore = () => {
const payload: WorkContentState = {
detailRows: rowData.value.map(item => ({ ...item }))
}
zxFwPricingStore.setKeyState(props.storageKey, payload)
emitCheckedChange()
}
const loadFromStore = async () => {
const state = await zxFwPricingStore.loadKeyState<WorkContentState>(props.storageKey)
if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) {
rowData.value = state.detailRows.map(item => ({
...item,
type: item.custom ? '自定义' : (item.type || '基本工作'),
path: Array.isArray(item.path) && item.path.length ? item.path : ['自定义', item.content || '未命名']
})) as WorkContentRow[]
} else {
rowData.value = buildDefaultRowsFromDict()
saveToStore()
}
emitCheckedChange()
}
const handleCheckedToggle = (id: string, checked: boolean) => {
const target = rowData.value.find(item => item.id === id)
if (!target) return
target.checked = checked
gridApi.value?.refreshCells({ force: true })
saveToStore()
}
const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
const data = params.data
if (!data) return ''
const wrapper = document.createElement('div')
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'space-between'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
wrapper.className = 'work-content-cell'
// 自定义行不显示 checkbox直接显示文本空时显示 placeholder
if (data.custom) {
const label = document.createElement('span')
if (!data.content) {
label.className = 'work-content-placeholder'
label.textContent = '点击输入工作内容'
} else {
label.className = 'work-content-text'
label.textContent = data.content
}
wrapper.appendChild(label)
return wrapper
}
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'work-content-check'
checkbox.checked = Boolean(data.checked)
checkbox.addEventListener('change', () => {
handleCheckedToggle(data.id, checkbox.checked)
})
const label = document.createElement('span')
label.className = 'work-content-text'
label.textContent = String(data.content || '')
wrapper.appendChild(checkbox)
wrapper.appendChild(label)
return wrapper
}
const columnDefs: ColDef<WorkContentRow>[] = [
{
headerName: '序号',
minWidth: 60,
width: 70,
pinned: 'left',
suppressMovable: true,
editable: false,
valueGetter: params => (params.node?.rowIndex ?? 0) + 1
},
{
headerName: '工作内容',
field: 'content',
minWidth: 320,
flex: 2,
editable: params => Boolean(params.data?.custom),
valueParser: params => String(params.newValue || '').trim(),
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.5' },
cellRenderer: contentCellRenderer
},
{
headerName: '工作类型',
field: 'type',
minWidth: 100,
width: 120,
editable: false,
valueFormatter: (params: ValueFormatterParams<WorkContentRow>) => String(params.value || '')
},
{
headerName: '备注',
field: 'remark',
minWidth: 180,
flex: 1.2,
editable: true,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
cellClass: 'remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
},
valueFormatter: params => params.value || '点击输入'
},
{
headerName: '操作',
colId: 'actions',
minWidth: 92,
maxWidth: 110,
flex: 0.8,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: defineComponent({
name: 'HtFeeGridActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<WorkContentRow>>,
required: true
}
},
setup(rendererProps) {
return () => {
const row = rendererProps.params.data
if (!row?.custom) return null
const onDelete = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
requestDeleteRow(row.id, row.content)
}
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete
},
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
)
}
}
})
}
]
const addCustomRow = () => {
const ts = Date.now()
rowData.value.push({
id: `custom-${ts}`,
content: '',
type: '自定义' as WorkType,
remark: '',
checked: false,
custom: true,
path: ['自定义', `自定义-${ts}`]
})
saveToStore()
}
const onGridReady = (event: GridReadyEvent<WorkContentRow>) => {
gridApi.value = event.api
}
const onCellValueChanged = (event: CellValueChangedEvent<WorkContentRow>) => {
const row = event.data
if (!row) return
if (event.colDef.field === 'content' && row.custom) {
row.path = ['自定义', row.content || `自定义-${row.id}`]
}
if (event.colDef.field === 'type' && row.custom) {
row.type = '自定义'
}
saveToStore()
}
onMounted(() => {
void loadFromStore()
})
onBeforeUnmount(() => {
saveToStore()
})
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const deleteRow = (id: string) => {
rowData.value = rowData.value.filter(item => item.id !== id)
saveToStore()
}
const confirmDeleteRow = () => {
const id = pendingDeleteRowId.value
if (!id) return
deleteRow(id)
deleteConfirmOpen.value = false
pendingDeleteRowId.value = null
pendingDeleteRowName.value = ''
}
</script>
<template>
<div class="h-full min-h-0 xmMx">
<div class="h-full min-h-0 rounded-2xl border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm">
<div class="flex items-center justify-between border-b border-border/60 px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3>
<Button type="button" size="sm" variant="outline" class="cursor-pointer" @click="addCustomRow">
添加自定义内容
</Button>
</div>
<div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full">
<AgGridVue
:style="agGridStyle"
:rowData="rowData"
:columnDefs="columnDefs"
:theme="myTheme"
:getRowId="(params: { data: WorkContentRow }) => params.data.id"
:animateRows="true"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:singleClickEdit="true"
:stopEditingWhenCellsLoseFocus="true"
:defaultColDef="{ resizable: true, sortable: false, filter: false }"
:suppressColumnVirtualisation="false"
:suppressRowVirtualisation="false"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
/>
</div>
</div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认删除行</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将删除{{ pendingDeleteRowName }}这条明细是否继续
</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="confirmDeleteRow">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</template>
<style scoped>
:deep(.work-content-placeholder) {
color: #94a3b8;
font-style: italic;
min-width: 0;
flex: 1;
}
:deep(.work-content-cell) {
display: flex;
width: 100%;
align-items: center;
gap: 8px;
}
:deep(.work-content-text) {
min-width: 0;
flex: 1;
white-space: normal;
word-break: break-word;
line-height: 1.5;
}
:deep(.work-content-check) {
width: 14px;
height: 14px;
cursor: pointer;
}
</style>