419 lines
12 KiB
Vue
419 lines
12 KiB
Vue
<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>
|