857 lines
29 KiB
Vue
857 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, PropType, ref, watch } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { AgGridVue } from 'ag-grid-vue3'
|
||
import type {
|
||
CellValueChangedEvent,
|
||
ColDef,
|
||
FirstDataRenderedEvent,
|
||
GridApi,
|
||
GridReadyEvent,
|
||
ICellRendererParams,
|
||
IGroupCellRendererParams,
|
||
IRowNode,
|
||
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 { agGridDefaultColDef, myTheme, agGridStyle } from '@/lib/diyAgGridOptions'
|
||
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
|
||
import { getServiceDictItemById, getWorkListEntries, wholeProcessTasks } from '@/sql'
|
||
import { WorkType } from '@/sql'
|
||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||
import { useKvStore } from '@/pinia/kv'
|
||
import { Trash2 } from 'lucide-vue-next'
|
||
|
||
interface WorkContentRow {
|
||
id: string
|
||
content: string
|
||
type: WorkType
|
||
dictOrder?: number
|
||
serviceGroup?: string
|
||
serviceid?: number | null
|
||
remark: string
|
||
checked: boolean
|
||
custom: boolean
|
||
isAddTrigger?: 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
|
||
contractId?: string
|
||
projectInfoKey?: string
|
||
dictMode?: 'service' | 'additional' | 'none'
|
||
}>(), {
|
||
title: '',
|
||
projectInfoKey: 'xm-base-info-v1',
|
||
dictMode: 'none'
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
checkedChange: [value: string[]]
|
||
}>()
|
||
const { t, locale } = useI18n()
|
||
|
||
const zxFwPricingStore = useZxFwPricingStore()
|
||
const kvStore = useKvStore()
|
||
const gridApi = ref<GridApi<WorkContentRow> | null>(null)
|
||
const rowData = ref<WorkContentRow[]>([])
|
||
const isWholeProcessGroupedMode = ref(false)
|
||
const groupedServiceGroups = ref<string[]>([])
|
||
const defaultColDef = {
|
||
...(agGridDefaultColDef as ColDef<WorkContentRow>),
|
||
resizable: true,
|
||
sortable: false,
|
||
filter: false
|
||
}
|
||
|
||
const syncGroupedRowsRender = async () => {
|
||
await nextTick()
|
||
const api = gridApi.value
|
||
if (!api || api.isDestroyed?.()) return
|
||
if (isWholeProcessGroupedMode.value) {
|
||
api.expandAll()
|
||
}
|
||
api.refreshClientSideRowModel('group')
|
||
api.refreshCells({ force: true })
|
||
api.redrawRows()
|
||
setTimeout(() => {
|
||
const liveApi = gridApi.value
|
||
if (!liveApi || liveApi.isDestroyed?.()) return
|
||
if (isWholeProcessGroupedMode.value) {
|
||
liveApi.expandAll()
|
||
}
|
||
liveApi.refreshCells({ force: true })
|
||
liveApi.redrawRows()
|
||
}, 16)
|
||
}
|
||
|
||
const toServiceId = (value: unknown): number | null => {
|
||
const parsed = Number(value)
|
||
if (!Number.isSafeInteger(parsed)) return null
|
||
return parsed
|
||
}
|
||
|
||
const getStableDictRowKeyFromId = (idRaw: unknown) => {
|
||
const id = String(idRaw || '').trim()
|
||
if (!id.startsWith('dict-')) return ''
|
||
const matched = /^dict-(-?\d+)-(\d+)(?:-|$)/.exec(id)
|
||
if (!matched) return ''
|
||
return `sid:${matched[1]}|order:${matched[2]}`
|
||
}
|
||
|
||
const getDictRowMergeKey = (row: Pick<WorkContentRow, 'id' | 'dictOrder' | 'content' | 'serviceid' | 'serviceGroup'>) => {
|
||
const fromId = getStableDictRowKeyFromId(row.id)
|
||
if (fromId) return fromId
|
||
const content = String(row.content || '').trim()
|
||
const serviceid = toServiceId(row.serviceid)
|
||
const dictOrder = Number(row.dictOrder)
|
||
if (serviceid != null && Number.isFinite(dictOrder)) return `sid:${serviceid}|order:${dictOrder}`
|
||
if (serviceid != null) return `sid:${serviceid}|content:${content}`
|
||
const groupName = String(row.serviceGroup || '').trim()
|
||
if (groupName) return `group:${groupName}|content:${content}`
|
||
return `content:${content}`
|
||
}
|
||
|
||
|
||
|
||
const loadProjectIndustryId = async () => {
|
||
try {
|
||
const baseInfo = await kvStore.getItem<{ projectIndustry?: unknown }>(props.projectInfoKey)
|
||
const raw = typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||
if (raw.toUpperCase() === 'E2') return 0
|
||
if (raw.toUpperCase() === 'E3') return 1
|
||
if (raw.toUpperCase() === 'E4') return 2
|
||
const n = Number(raw)
|
||
return Number.isFinite(n) ? n : null
|
||
} catch (_error) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const buildDefaultRowsFromDict = async (): Promise<WorkContentRow[]> => {
|
||
const rows: WorkContentRow[] = []
|
||
const entries = getWorkListEntries(locale.value) as Array<{ text: string; serviceid: number; order: number; type: number }>
|
||
|
||
let filtered: typeof entries = []
|
||
let groupedServiceIds: number[] = []
|
||
let groupedBy: 'fid' | 'sid' | null = null
|
||
let matchedWholeProcessGroup: { fid: number; industry: number; sid: number[] } | null = null
|
||
isWholeProcessGroupedMode.value = false
|
||
groupedServiceGroups.value = []
|
||
if (props.dictMode === 'service') {
|
||
const sid = Number(props.serviceId)
|
||
const industryId = await loadProjectIndustryId()
|
||
const wholeProcessGroupByFid = wholeProcessTasks.find(
|
||
item => Number(item.fid) === sid && Number(item.industry) === industryId
|
||
)
|
||
|
||
const wholeProcessGroup = wholeProcessGroupByFid
|
||
groupedBy = wholeProcessGroupByFid ? 'fid' : null
|
||
if (wholeProcessGroup) {
|
||
groupedServiceIds = Array.isArray(wholeProcessGroup.sid)
|
||
? wholeProcessGroup.sid.map(id => Number(id)).filter(Number.isFinite)
|
||
: []
|
||
matchedWholeProcessGroup = {
|
||
fid: Number(wholeProcessGroup.fid),
|
||
industry: Number(wholeProcessGroup.industry),
|
||
sid: [...groupedServiceIds]
|
||
}
|
||
const groupedSet = new Set(groupedServiceIds)
|
||
filtered = entries.filter(e => groupedSet.has(Number(e.serviceid)))
|
||
isWholeProcessGroupedMode.value = groupedServiceIds.length > 0
|
||
groupedServiceGroups.value = groupedServiceIds.map(sid => {
|
||
const serviceItem = getServiceDictItemById(sid) as { code?: string; name?: string } | undefined
|
||
return serviceItem
|
||
? `${String(serviceItem.code || '').trim()} ${String(serviceItem.name || '').trim()}`.trim()
|
||
: String(sid)
|
||
})
|
||
} else {
|
||
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 []
|
||
}
|
||
|
||
if (isWholeProcessGroupedMode.value) {
|
||
const sidIndex = new Map(groupedServiceIds.map((sid, index) => [sid, index]))
|
||
filtered.sort((a, b) => {
|
||
const indexA = sidIndex.get(Number(a.serviceid)) ?? Number.MAX_SAFE_INTEGER
|
||
const indexB = sidIndex.get(Number(b.serviceid)) ?? Number.MAX_SAFE_INTEGER
|
||
if (indexA !== indexB) return indexA - indexB
|
||
return a.order - b.order
|
||
})
|
||
} else {
|
||
filtered.sort((a, b) => a.order - b.order)
|
||
}
|
||
|
||
for (const entry of filtered) {
|
||
const content = String(entry.text || '').trim()
|
||
if (!content) continue
|
||
const typeLabel = ((): WorkType => {
|
||
if (entry.type === 1) return t('workContent.type.optional') as WorkType
|
||
if (entry.type === 2) return t('workContent.type.daily') as WorkType
|
||
if (entry.type === 3) return t('workContent.type.special') as WorkType
|
||
if (entry.type === 4) return t('workContent.type.additional') as WorkType
|
||
return t('workContent.type.basic') as WorkType
|
||
})()
|
||
const serviceItem = getServiceDictItemById(entry.serviceid) as { code?: string; name?: string } | undefined
|
||
const serviceGroup = serviceItem
|
||
? `${String(serviceItem.code || '').trim()} ${String(serviceItem.name || '').trim()}`.trim()
|
||
: ''
|
||
rows.push({
|
||
id: `dict-${entry.serviceid}-${entry.order}`,
|
||
content,
|
||
type: typeLabel,
|
||
dictOrder: entry.order,
|
||
serviceGroup,
|
||
serviceid: toServiceId(entry.serviceid),
|
||
remark: '',
|
||
checked: true,
|
||
custom: false,
|
||
path: isWholeProcessGroupedMode.value && serviceGroup
|
||
? [serviceGroup, content]
|
||
: [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() || t('workContent.currentRow')
|
||
deleteConfirmOpen.value = true
|
||
}
|
||
const checkedIds = computed(() =>
|
||
rowData.value.filter(item => !isAddTriggerRow(item) && item.checked).map(item => item.id)
|
||
)
|
||
|
||
// 导出用:自定义内容全部包含,默认词典内容只含勾选的
|
||
const selectedTexts = computed(() =>
|
||
rowData.value
|
||
.filter(item => !isAddTriggerRow(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: getPersistableRows(rowData.value).map(item => ({ ...item }))
|
||
}
|
||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||
emitCheckedChange()
|
||
}
|
||
|
||
const loadFromStore = async () => {
|
||
const defaultRows =
|
||
props.dictMode === 'none'
|
||
? []
|
||
: await buildDefaultRowsFromDict()
|
||
const state = await zxFwPricingStore.loadKeyState<WorkContentState>(props.storageKey)
|
||
if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) {
|
||
const persistedRows = state.detailRows.map(item => ({
|
||
...item,
|
||
type: item.custom ? t('workContent.type.custom') : (item.type || t('workContent.type.basic')),
|
||
checked: item.custom ? false : item.checked !== false,
|
||
serviceid: toServiceId(item.serviceid),
|
||
path: Array.isArray(item.path) && item.path.length ? item.path : [t('workContent.type.custom'), item.content || t('workContent.unnamed')]
|
||
})) as WorkContentRow[]
|
||
|
||
const defaultGroupServiceIdMap = new Map<string, number>()
|
||
for (const row of defaultRows) {
|
||
const groupName = String(row.serviceGroup || '').trim()
|
||
const serviceid = toServiceId(row.serviceid)
|
||
if (!groupName || serviceid == null) continue
|
||
defaultGroupServiceIdMap.set(groupName, serviceid)
|
||
}
|
||
for (const row of persistedRows) {
|
||
if (row.serviceid != null) continue
|
||
const groupName = String(row.serviceGroup || '').trim()
|
||
if (!groupName) continue
|
||
const fallbackServiceId = defaultGroupServiceIdMap.get(groupName)
|
||
if (fallbackServiceId != null) {
|
||
row.serviceid = fallbackServiceId
|
||
}
|
||
}
|
||
|
||
// 按最新词典规则重建默认行,再合并历史勾选/备注,保证分组规则变更后立即生效。
|
||
if (defaultRows.length > 0) {
|
||
const persistedCustomRows = persistedRows.filter(item => item.custom)
|
||
const persistedDictRows = persistedRows.filter(item => !item.custom)
|
||
const persistedByKey = new Map(
|
||
persistedDictRows.map(item => [getDictRowMergeKey(item), item])
|
||
)
|
||
const mergedDictRows = defaultRows.map(item => {
|
||
const key = getDictRowMergeKey(item)
|
||
const old = persistedByKey.get(key)
|
||
if (!old) return item
|
||
return {
|
||
...item,
|
||
checked: old.checked !== false,
|
||
remark: String(old.remark || '')
|
||
}
|
||
})
|
||
rowData.value = withAddTriggerRows([...mergedDictRows, ...persistedCustomRows])
|
||
saveToStore()
|
||
} else {
|
||
rowData.value = withAddTriggerRows(persistedRows)
|
||
isWholeProcessGroupedMode.value = rowData.value.some(
|
||
item => !item.custom && Boolean(String(item.serviceGroup || '').trim())
|
||
)
|
||
}
|
||
} else {
|
||
rowData.value = withAddTriggerRows(defaultRows)
|
||
saveToStore()
|
||
}
|
||
emitCheckedChange()
|
||
await syncGroupedRowsRender()
|
||
}
|
||
|
||
const handleCheckedToggle = (id: string, checked: boolean) => {
|
||
const target = rowData.value.find(item => item.id === id)
|
||
if (!target || isAddTriggerRow(target)) return
|
||
target.checked = checked
|
||
gridApi.value?.refreshCells({ force: true })
|
||
saveToStore()
|
||
}
|
||
|
||
const getGroupCheckableRows = (node?: IRowNode<WorkContentRow> | null) => {
|
||
if (!node) return []
|
||
return (node.allLeafChildren || [])
|
||
.map(item => item.data)
|
||
.filter((item): item is WorkContentRow => Boolean(item && !isAddTriggerRow(item) && !item.custom))
|
||
}
|
||
|
||
const handleGroupCheckedToggle = (node: IRowNode<WorkContentRow>, checked: boolean) => {
|
||
const groupRows = getGroupCheckableRows(node)
|
||
if (groupRows.length === 0) return
|
||
const targetIds = new Set(groupRows.map(item => item.id))
|
||
let changed = false
|
||
for (const row of rowData.value) {
|
||
if (!targetIds.has(row.id)) continue
|
||
if (row.checked === checked) continue
|
||
row.checked = checked
|
||
changed = true
|
||
}
|
||
if (!changed) return
|
||
gridApi.value?.refreshCells({ force: true })
|
||
gridApi.value?.redrawRows()
|
||
saveToStore()
|
||
}
|
||
|
||
const groupRowRendererParams = computed<IGroupCellRendererParams<WorkContentRow> | undefined>(() => {
|
||
if (!isWholeProcessGroupedMode.value) return undefined
|
||
return {
|
||
suppressCount: true,
|
||
innerRenderer: (params: ICellRendererParams<WorkContentRow>) => {
|
||
const wrapper = document.createElement('div')
|
||
wrapper.className = 'work-content-group-row'
|
||
const checkbox = document.createElement('input')
|
||
checkbox.type = 'checkbox'
|
||
checkbox.className = 'work-content-group-check'
|
||
const rows = getGroupCheckableRows(params.node)
|
||
const checkedCount = rows.filter(item => item.checked).length
|
||
checkbox.checked = rows.length > 0 && checkedCount === rows.length
|
||
checkbox.indeterminate = checkedCount > 0 && checkedCount < rows.length
|
||
checkbox.disabled = rows.length === 0
|
||
checkbox.addEventListener('mousedown', event => event.stopPropagation())
|
||
checkbox.addEventListener('click', event => event.stopPropagation())
|
||
checkbox.addEventListener('change', event => {
|
||
event.stopPropagation()
|
||
handleGroupCheckedToggle(params.node, checkbox.checked)
|
||
})
|
||
const label = document.createElement('span')
|
||
label.className = 'work-content-group-label'
|
||
label.textContent = String(params.valueFormatted || params.value || params.node.key || '')
|
||
wrapper.append(checkbox, label)
|
||
return wrapper
|
||
}
|
||
}
|
||
})
|
||
|
||
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'
|
||
if (isAddTriggerRow(data)) {
|
||
const label = document.createElement('span')
|
||
label.className = 'work-content-placeholder'
|
||
label.textContent = String(data.content || t('workContent.addCustom'))
|
||
wrapper.appendChild(label)
|
||
return wrapper
|
||
}
|
||
// 自定义行不显示 checkbox,直接显示文本;空时显示 placeholder
|
||
if (data.custom) {
|
||
const label = document.createElement('span')
|
||
if (!data.content) {
|
||
label.className = 'work-content-placeholder'
|
||
label.textContent = t('workContent.clickToInputContent')
|
||
} 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: t('workContent.columns.no'),
|
||
minWidth: 60,
|
||
width: 70,
|
||
suppressMovable: true,
|
||
editable: false,
|
||
colSpan: params => (isAddTriggerRow(params.data) ? 5 : 1),
|
||
valueGetter: params => {
|
||
if (!params.node || params.node.group || isAddTriggerRow(params.data)) return ''
|
||
if (!isWholeProcessGroupedMode.value) return (params.node.rowIndex ?? 0) + 1
|
||
const siblings = params.node.parent?.childrenAfterSort || []
|
||
const visibleLeafSiblings = siblings.filter(node => !node.group && !isAddTriggerRow(node.data as WorkContentRow))
|
||
const index = visibleLeafSiblings.findIndex(node => node.id === params.node?.id)
|
||
return index >= 0 ? index + 1 : ''
|
||
},
|
||
cellRenderer: (params: ICellRendererParams<WorkContentRow>) => {
|
||
const row = params.data
|
||
if (!isAddTriggerRow(row)) return params.value
|
||
const button = document.createElement('button')
|
||
button.type = 'button'
|
||
button.className =
|
||
'inline-flex h-full w-full cursor-pointer items-center justify-center rounded-none border-0 bg-transparent px-3 py-3 text-sm font-medium text-blue-700 hover:bg-transparent focus:outline-none'
|
||
button.textContent = `+ ${t('workContent.addCustom')}`
|
||
button.addEventListener('click', event => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
addCustomRow(String(row?.serviceGroup || '').trim())
|
||
})
|
||
return button
|
||
}
|
||
},
|
||
{
|
||
headerName: t('workContent.columns.content'),
|
||
field: 'content',
|
||
minWidth: 320,
|
||
flex: 2,
|
||
cellClass: 'work-content-main-cell',
|
||
editable: params => Boolean(params.data?.custom && !isAddTriggerRow(params.data)),
|
||
|
||
valueParser: params => String(params.newValue || '').trim(),
|
||
wrapText: true,
|
||
autoHeight: true,
|
||
|
||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.5' },
|
||
cellRenderer: contentCellRenderer
|
||
},
|
||
{
|
||
headerName: t('workContent.columns.type'),
|
||
field: 'type',
|
||
minWidth: 100,
|
||
width: 120,
|
||
editable: false,
|
||
valueFormatter: (params: ValueFormatterParams<WorkContentRow>) =>
|
||
isAddTriggerRow(params.data) ? '' : String(params.value || '')
|
||
},
|
||
{
|
||
headerName: t('workContent.columns.remark'),
|
||
field: 'remark',
|
||
minWidth: 180,
|
||
flex: 1.2,
|
||
editable: params => !isAddTriggerRow(params.data),
|
||
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 => (isAddTriggerRow(params.data) ? '' : (params.value || t('workContent.clickToInput')))
|
||
},
|
||
{
|
||
headerName: t('workContent.columns.actions'),
|
||
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', t('common.delete'))]
|
||
)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
]
|
||
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
|
||
|
||
const isAddTriggerRow = (row?: WorkContentRow | null) => Boolean(row?.isAddTrigger)
|
||
const getPersistableRows = (rows: WorkContentRow[]) => rows.filter(item => !isAddTriggerRow(item))
|
||
|
||
const createAddTriggerRow = (groupName?: string): WorkContentRow => {
|
||
const suffix = groupName ? String(groupName).trim() : 'root'
|
||
return {
|
||
id: `add-trigger-${suffix}`,
|
||
content: t('workContent.addCustom'),
|
||
type: t('workContent.type.custom') as WorkType,
|
||
serviceGroup: groupName || '',
|
||
serviceid: null,
|
||
remark: '',
|
||
checked: false,
|
||
custom: false,
|
||
isAddTrigger: true,
|
||
path: groupName ? [groupName, '__add__'] : ['__add__']
|
||
}
|
||
}
|
||
|
||
const withAddTriggerRows = (rows: WorkContentRow[]) => {
|
||
const pureRows = getPersistableRows(rows)
|
||
if (isWholeProcessGroupedMode.value) {
|
||
const groupedMap = new Map<string, WorkContentRow[]>()
|
||
for (const row of pureRows) {
|
||
const groupName = String(row.serviceGroup || '').trim() || t('workContent.ungrouped')
|
||
if (!groupedMap.has(groupName)) groupedMap.set(groupName, [])
|
||
groupedMap.get(groupName)?.push(row)
|
||
}
|
||
const groupOrder = groupedServiceGroups.value.length
|
||
? [...groupedServiceGroups.value]
|
||
: [...groupedMap.keys()]
|
||
const result: WorkContentRow[] = []
|
||
const used = new Set<string>()
|
||
for (const groupName of groupOrder) {
|
||
used.add(groupName)
|
||
result.push(...(groupedMap.get(groupName) || []))
|
||
result.push(createAddTriggerRow(groupName))
|
||
}
|
||
for (const [groupName, groupRows] of groupedMap.entries()) {
|
||
if (used.has(groupName)) continue
|
||
result.push(...groupRows)
|
||
result.push(createAddTriggerRow(groupName))
|
||
}
|
||
return result
|
||
}
|
||
return [...pureRows, createAddTriggerRow()]
|
||
}
|
||
|
||
const getDataPath = (data: WorkContentRow) => {
|
||
const path = Array.isArray(data?.path)
|
||
? data.path.map(segment => String(segment || '').trim()).filter(Boolean)
|
||
: []
|
||
if (path.length > 0) return path
|
||
const fallback = String(data?.id || '').trim()
|
||
return [fallback || '__row__']
|
||
}
|
||
|
||
const addCustomRow = (groupName?: string) => {
|
||
const ts = Date.now()
|
||
const finalGroupName = isWholeProcessGroupedMode.value
|
||
? String(groupName || groupedServiceGroups.value[0] || '').trim()
|
||
: ''
|
||
const finalServiceId = isWholeProcessGroupedMode.value
|
||
? (() => {
|
||
const pureRows = getPersistableRows(rowData.value)
|
||
const hit = pureRows.find(item => String(item.serviceGroup || '').trim() === finalGroupName && item.serviceid != null)
|
||
return hit?.serviceid ?? null
|
||
})()
|
||
: null
|
||
const nextRow: WorkContentRow = {
|
||
id: `custom-${ts}`,
|
||
content: '',
|
||
type: t('workContent.type.custom') as WorkType,
|
||
serviceGroup: finalGroupName,
|
||
serviceid: finalServiceId,
|
||
remark: '',
|
||
checked: false,
|
||
custom: true,
|
||
path: isWholeProcessGroupedMode.value && finalGroupName
|
||
? [finalGroupName, `${t('workContent.type.custom')}-${ts}`]
|
||
: [t('workContent.type.custom'), `${t('workContent.type.custom')}-${ts}`]
|
||
}
|
||
const pureRows = getPersistableRows(rowData.value)
|
||
pureRows.push(nextRow)
|
||
rowData.value = withAddTriggerRows(pureRows)
|
||
saveToStore()
|
||
setTimeout(() => {
|
||
const rowIndex = rowData.value.findIndex(item => item.id === nextRow.id)
|
||
if (rowIndex >= 0) gridApi.value?.startEditingCell({ rowIndex, colKey: 'content' })
|
||
}, 0)
|
||
}
|
||
|
||
const onGridReady = (event: GridReadyEvent<WorkContentRow>) => {
|
||
gridApi.value = event.api
|
||
void syncGroupedRowsRender()
|
||
}
|
||
|
||
const onFirstDataRendered = (_event: FirstDataRenderedEvent<WorkContentRow>) => {
|
||
void syncGroupedRowsRender()
|
||
}
|
||
|
||
const onCellValueChanged = (event: CellValueChangedEvent<WorkContentRow>) => {
|
||
const row = event.data
|
||
if (!row || isAddTriggerRow(row)) return
|
||
if (event.colDef.field === 'content' && row.custom) {
|
||
const groupName = String(row.serviceGroup || '').trim()
|
||
row.path = isWholeProcessGroupedMode.value && groupName
|
||
? [groupName, row.content || `${t('workContent.type.custom')}-${row.id}`]
|
||
: [t('workContent.type.custom'), row.content || `${t('workContent.type.custom')}-${row.id}`]
|
||
}
|
||
if (event.colDef.field === 'type' && row.custom) {
|
||
row.type = t('workContent.type.custom') as WorkType
|
||
}
|
||
saveToStore()
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadFromStore()
|
||
})
|
||
|
||
watch(isWholeProcessGroupedMode, () => {
|
||
void syncGroupedRowsRender()
|
||
})
|
||
|
||
watch(
|
||
() => rowData.value.length,
|
||
() => {
|
||
void syncGroupedRowsRender()
|
||
}
|
||
)
|
||
|
||
watch(locale, () => {
|
||
void loadFromStore()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
gridApi.value?.stopEditing()
|
||
saveToStore()
|
||
})
|
||
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
||
deleteConfirmOpen.value = open
|
||
}
|
||
|
||
|
||
const deleteRow = (id: string) => {
|
||
rowData.value = withAddTriggerRows(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 || t('workContent.title') }}</h3>
|
||
</div>
|
||
<div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full">
|
||
<AgGridVue
|
||
:style="agGridStyle"
|
||
:rowData="rowData"
|
||
:columnDefs="gridColumnDefs"
|
||
:theme="myTheme"
|
||
:getRowId="(params: { data: WorkContentRow }) => params.data.id"
|
||
:treeData="isWholeProcessGroupedMode"
|
||
:getDataPath="getDataPath"
|
||
:groupDefaultExpanded="isWholeProcessGroupedMode ? -1 : 0"
|
||
:groupDisplayType="isWholeProcessGroupedMode ? 'groupRows' : undefined"
|
||
:groupRowRendererParams="groupRowRendererParams"
|
||
:animateRows="true"
|
||
:localeText="AG_GRID_LOCALE_CN"
|
||
:tooltipShowDelay="500"
|
||
:singleClickEdit="true"
|
||
:stopEditingWhenCellsLoseFocus="true"
|
||
:enterNavigatesVertically="true"
|
||
:enterNavigatesVerticallyAfterEdit="true"
|
||
:defaultColDef="defaultColDef"
|
||
:suppressColumnVirtualisation="false"
|
||
:suppressRowVirtualisation="false"
|
||
@grid-ready="onGridReady"
|
||
@first-data-rendered="onFirstDataRendered"
|
||
@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">{{ t('workContent.dialog.deleteTitle') }}</AlertDialogTitle>
|
||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||
{{ t('workContent.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
|
||
</AlertDialogDescription>
|
||
<div class="mt-4 flex items-center justify-end gap-2">
|
||
<AlertDialogCancel as-child>
|
||
<Button variant="outline">{{ t('common.cancel') }}</Button>
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction as-child>
|
||
<Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
|
||
</AlertDialogAction>
|
||
</div>
|
||
</AlertDialogContent>
|
||
</AlertDialogPortal>
|
||
</AlertDialogRoot>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
:deep(.ag-cell) {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
:deep(.ag-cell .ag-cell-wrapper) {
|
||
width: 100%;
|
||
}
|
||
|
||
:deep(.ag-cell .ag-cell-value) {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
:deep(.work-content-placeholder) {
|
||
color: var(--muted-foreground);
|
||
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;
|
||
}
|
||
|
||
:deep(.work-content-group-row) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
:deep(.work-content-group-check) {
|
||
width: 14px;
|
||
height: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
:deep(.work-content-group-check:disabled) {
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
:deep(.work-content-group-label) {
|
||
min-width: 0;
|
||
word-break: break-word;
|
||
}
|
||
|
||
:deep(.work-content-main-cell.ag-cell-auto-height),
|
||
:deep(.remark-wrap-cell.ag-cell-auto-height) {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
:deep(.work-content-main-cell.ag-cell-auto-height .ag-cell-wrapper),
|
||
:deep(.work-content-main-cell.ag-cell-auto-height .ag-cell-value),
|
||
:deep(.remark-wrap-cell.ag-cell-auto-height .ag-cell-wrapper),
|
||
:deep(.remark-wrap-cell.ag-cell-auto-height .ag-cell-value) {
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
</style>
|