Files
cn-rdms-web/src/views/personal-center/my-item/index.vue

668 lines
19 KiB
Vue

<script setup lang="tsx">
import { computed, markRaw, nextTick, onActivated, reactive, ref } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElButton, ElMessageBox, ElTag, ElTooltip } from 'element-plus';
import { useBoolean } from '@sa/hooks';
import {
fetchBatchDeletePersonalItems,
fetchBindPersonalItemsToExecution,
fetchChangePersonalItemStatus,
fetchDeletePersonalItem,
fetchGetPersonalItemDetail,
fetchGetPersonalItemPage
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import PersonalItemBindExecutionDialog from './modules/personal-item-bind-execution-dialog.vue';
import PersonalItemDetailDialog from './modules/personal-item-detail-dialog.vue';
import PersonalItemOperateDialog from './modules/personal-item-operate-dialog.vue';
import PersonalItemSearch from './modules/personal-item-search.vue';
import PersonalItemStatusActionDialog from './modules/personal-item-status-action-dialog.vue';
import {
formatPersonalItemDateRange,
formatPersonalItemDateTime,
formatPersonalItemOwnerName,
formatPersonalItemProgress,
getPersonalItemStatusLabel,
resolvePersonalItemStatusTagType
} from './modules/personal-item-shared';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiPlay from '~icons/mdi/play';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'MyItem' });
type DetailTab = 'worklog';
type PersonalItemOperateType = UI.TableOperateType | 'view';
interface PersonalItemRowAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'warning' | 'danger';
disabled?: boolean;
onClick: () => void | Promise<void>;
}
const lifecycleActionIconMap: Record<string, object> = {
start: markRaw(IconMdiPlay),
pause: markRaw(IconMdiPause),
resume: markRaw(IconMdiRestart),
reopen: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline),
complete: markRaw(IconMdiCheckCircleOutline)
};
const lifecycleActionTypeMap: Record<string, PersonalItemRowAction['type']> = {
cancel: 'danger',
pause: 'warning',
complete: 'success',
resume: 'primary',
reopen: 'primary',
start: 'primary'
};
const lifecycleActionOrder: Record<string, number> = {
pause: 1,
cancel: 2,
complete: 3,
resume: 4,
reopen: 5,
start: 6
};
const authStore = useAuthStore();
const currentUserId = computed(() => {
const rawUserId = authStore.userInfo.userId;
return rawUserId ? String(rawUserId) : '';
});
function getInitSearchParams(): Api.PersonalItem.PersonalItemSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: undefined,
ownerId: currentUserId.value || undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformPageResult(
response: Awaited<ReturnType<typeof fetchGetPersonalItemPage>>,
pageNo: number,
pageSize: number
) {
if (!response.error) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const searchParams = reactive(getInitSearchParams());
const tableRef = ref<TableInstance>();
const checkedRowIds = ref<string[]>([]);
const bindExecutionSubmitting = ref(false);
const selectedCount = computed(() => checkedRowIds.value.length);
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetPersonalItemPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'selection', type: 'selection', width: 48 },
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'taskTitle',
label: '事项标题',
minWidth: 260,
showOverflowTooltip: true
},
{
prop: 'ownerName',
label: '负责人',
minWidth: 140,
formatter: row => formatPersonalItemOwnerName(row)
},
{
prop: 'statusCode',
label: '状态',
width: 120,
align: 'center',
formatter: row => (
<ElTag type={resolvePersonalItemStatusTagType(row.statusCode)}>
{getPersonalItemStatusLabel(row.statusCode)}
</ElTag>
)
},
{
prop: 'progressRate',
label: '进度',
width: 100,
align: 'center',
formatter: row => formatPersonalItemProgress(row.progressRate)
},
{
prop: 'plannedDateRange',
label: '计划日期',
minWidth: 220,
formatter: row => formatPersonalItemDateRange(row.plannedStartDate, row.plannedEndDate)
},
{
prop: 'actualDateRange',
label: '实际日期',
minWidth: 220,
formatter: row => formatPersonalItemDateRange(row.actualStartDate, row.actualEndDate)
},
{
prop: 'updateTime',
label: '最近更新',
minWidth: 180,
formatter: row => formatPersonalItemDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 240,
align: 'center',
fixed: 'right',
formatter: row => renderRowActions(row)
}
]
});
const { bool: operateVisible, setTrue: openOperateDialog, setFalse: closeOperateDialog } = useBoolean();
const { bool: detailVisible, setTrue: openDetailDialog } = useBoolean();
const {
bool: bindExecutionVisible,
setTrue: openBindExecutionDialog,
setFalse: closeBindExecutionDialog
} = useBoolean();
const { bool: statusActionVisible, setTrue: openStatusActionDialog, setFalse: closeStatusActionDialog } = useBoolean();
const operateType = ref<PersonalItemOperateType>('add');
const editingData = ref<Api.PersonalItem.PersonalItem | null>(null);
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
const detailDefaultTab = ref<DetailTab>('worklog');
const currentStatusAction = ref<Api.PersonalItem.PersonalItemLifecycleAction | null>(null);
const currentStatusItem = ref<Api.PersonalItem.PersonalItem | null>(null);
async function openDetail(row: Api.PersonalItem.PersonalItem, defaultTab: DetailTab = 'worklog') {
const { error, data: latestDetail } = await fetchGetPersonalItemDetail(row.id);
detailData.value = error || !latestDetail ? row : latestDetail;
detailDefaultTab.value = defaultTab;
openDetailDialog();
}
function openView(row: Api.PersonalItem.PersonalItem) {
operateType.value = 'view';
editingData.value = row;
openOperateDialog();
}
// function createLifecycleAction(
// fallback: {
// key: string;
// tooltip: string;
// icon: object;
// type: PersonalItemRowAction['type'];
// actionCode: string;
// },
// action: Api.PersonalItem.PersonalItemLifecycleAction | null
// ): PersonalItemRowAction {
// return {
// key: fallback.key,
// tooltip: action?.actionName ?? fallback.tooltip,
// icon: fallback.icon,
// type: fallback.type,
// disabled: !action,
// onClick: async () =>
// handleStatusAction(currentStatusItem.value!, {
// actionCode: action?.actionCode ?? fallback.actionCode,
// actionName: action?.actionName ?? fallback.tooltip,
// needReason: action?.needReason ?? false
// })
// };
// }
function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAction[] {
currentStatusItem.value = row;
const rawLifecycleActions = [...(row.availableActions ?? [])];
const pauseAction = rawLifecycleActions.find(action => action.actionCode === 'pause') ?? null;
const cancelAction = rawLifecycleActions.find(action => action.actionCode === 'cancel') ?? null;
const completeAction = rawLifecycleActions.find(action => action.actionCode === 'complete') ?? null;
const lifecycleActions = rawLifecycleActions
.filter(action => !['pause', 'cancel', 'complete'].includes(action.actionCode))
.sort(
(left, right) => (lifecycleActionOrder[left.actionCode] ?? 99) - (lifecycleActionOrder[right.actionCode] ?? 99)
)
.map(action => ({
key: `status-${action.actionCode}`,
tooltip: action.actionName,
icon: markRaw(lifecycleActionIconMap[action.actionCode] ?? IconMdiSync),
type: lifecycleActionTypeMap[action.actionCode] ?? 'primary',
onClick: async () => handleStatusAction(row, action)
}));
return [
{
key: 'worklog',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: async () => openDetail(row, 'worklog')
},
{
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: async () => {
operateType.value = 'edit';
editingData.value = row;
openOperateDialog();
}
},
{
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: async () => handleDelete(row)
},
{
key: 'status-pause',
tooltip: pauseAction?.actionName ?? '暂停',
icon: markRaw(IconMdiPause),
type: 'warning',
disabled: !pauseAction,
onClick: async () =>
handleStatusAction(row, {
actionCode: pauseAction?.actionCode ?? 'pause',
actionName: pauseAction?.actionName ?? '暂停',
needReason: pauseAction?.needReason ?? false
})
},
// {
// key: 'status-cancel',
// tooltip: cancelAction?.actionName ?? '取消',
// icon: markRaw(IconMdiCloseCircleOutline),
// type: 'danger',
// disabled: !cancelAction,
// onClick: async () =>
// handleStatusAction(row, {
// actionCode: cancelAction?.actionCode ?? 'cancel',
// actionName: cancelAction?.actionName ?? '取消',
// needReason: cancelAction?.needReason ?? false
// })
// },
...lifecycleActions,
{
key: 'status-complete',
tooltip: completeAction?.actionName ?? '完成',
icon: markRaw(IconMdiCheckCircleOutline),
type: 'success',
disabled: !completeAction,
onClick: async () =>
handleStatusAction(row, {
actionCode: completeAction?.actionCode ?? 'complete',
actionName: completeAction?.actionName ?? '完成',
needReason: completeAction?.needReason ?? false
})
}
];
}
function renderRowActions(row: Api.PersonalItem.PersonalItem) {
return (
<div class="personal-item-row-actions" onClick={event => event.stopPropagation()}>
{buildRowActions(row).map(action => {
const Icon = action.icon as any;
return (
<ElTooltip key={action.key} content={action.tooltip}>
<span class="inline-flex">
<ElButton
link
type={action.type}
class="personal-item-row-action-btn"
disabled={action.disabled}
onClick={event => {
event.stopPropagation();
if (action.disabled) {
return;
}
action.onClick();
}}
>
<Icon class="text-15px" />
</ElButton>
</span>
</ElTooltip>
);
})}
</div>
);
}
function openAdd() {
operateType.value = 'add';
editingData.value = null;
openOperateDialog();
}
function handleSelectionChange(rows: Api.PersonalItem.PersonalItem[]) {
checkedRowIds.value = rows.map(item => item.id);
}
function resolveReloadPageAfterRemove() {
const currentPage = searchParams.pageNo ?? 1;
if (currentPage > 1 && data.value.length > 0 && checkedRowIds.value.length >= data.value.length) {
return currentPage - 1;
}
return currentPage;
}
async function reloadTable(page = searchParams.pageNo ?? 1) {
checkedRowIds.value = [];
await getDataByPage(page);
await nextTick();
tableRef.value?.clearSelection();
}
function resetSearchParams() {
Object.assign(searchParams, getInitSearchParams());
reloadTable(1);
}
function handleSearch() {
reloadTable(1);
}
function handleSubmitted() {
closeOperateDialog();
reloadTable(searchParams.pageNo ?? 1);
}
function handleDetailChanged(latestItem: Api.PersonalItem.PersonalItem) {
detailData.value = latestItem;
const targetIndex = data.value.findIndex(item => item.id === latestItem.id);
if (targetIndex >= 0) {
data.value.splice(targetIndex, 1, latestItem);
}
}
function handleStatusAction(row: Api.PersonalItem.PersonalItem, action: Api.PersonalItem.PersonalItemLifecycleAction) {
currentStatusItem.value = row;
currentStatusAction.value = action;
openStatusActionDialog();
}
async function handleStatusActionSubmit(reason: string | null) {
if (!currentStatusItem.value || !currentStatusAction.value) {
return;
}
const { error } = await fetchChangePersonalItemStatus(currentStatusItem.value.id, {
actionCode: currentStatusAction.value.actionCode,
reason
});
if (error) {
return;
}
closeStatusActionDialog();
window.$message?.success(`${currentStatusAction.value.actionName}成功`);
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleDelete(row: Api.PersonalItem.PersonalItem) {
try {
await ElMessageBox.confirm(`确定删除个人事项“${row.taskTitle}”吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
});
} catch {
return;
}
const { error } = await fetchDeletePersonalItem(row.id);
if (error) {
return;
}
window.$message?.success('删除成功');
await reloadTable(searchParams.pageNo ?? 1);
}
async function handleBatchDelete() {
if (!checkedRowIds.value.length) {
window.$message?.warning('请先选择个人事项');
return;
}
try {
await ElMessageBox.confirm(`确定删除选中的 ${selectedCount.value} 条个人事项吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
});
} catch {
return;
}
const targetPage = resolveReloadPageAfterRemove();
const { error } = await fetchBatchDeletePersonalItems({ ids: [...checkedRowIds.value] });
if (error) {
return;
}
window.$message?.success('批量删除成功');
await reloadTable(targetPage);
}
function handleOpenBindExecution() {
if (!checkedRowIds.value.length) {
window.$message?.warning('请先选择个人事项');
return;
}
openBindExecutionDialog();
}
async function handleBindExecutionSubmit(payload: { executionId: string }) {
bindExecutionSubmitting.value = true;
const targetPage = resolveReloadPageAfterRemove();
const { error } = await fetchBindPersonalItemsToExecution({
ids: [...checkedRowIds.value],
executionId: payload.executionId
});
bindExecutionSubmitting.value = false;
if (error) {
return;
}
closeBindExecutionDialog();
window.$message?.success('批量关联执行成功');
await reloadTable(targetPage);
}
onActivated(() => {
searchParams.ownerId = currentUserId.value || undefined;
reloadTable(searchParams.pageNo ?? 1);
});
</script>
<template>
<div class="flex-col-stretch gap-16px overflow-hidden">
<PersonalItemSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>个人事项</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton plain type="danger" :disabled="selectedCount === 0" @click="handleBatchDelete">
<template #icon>
<icon-ic-round-delete class="text-icon" />
</template>
批量删除
</ElButton>
<ElButton plain :disabled="selectedCount === 0" @click="handleOpenBindExecution">
<template #icon>
<icon-mdi-link-variant class="text-icon" />
</template>
批量关联执行
</ElButton>
<ElButton plain type="primary" @click="openAdd">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable
ref="tableRef"
v-loading="loading"
height="100%"
border
row-key="id"
:data="data"
@selection-change="handleSelectionChange"
>
<template v-for="col in columns" :key="String(col.prop)">
<ElTableColumn v-if="col.prop === 'taskTitle'" v-bind="col">
<template #default="{ row }">
<ElButton link type="primary" class="personal-item-title-link" @click.stop="openView(row)">
{{ row.taskTitle || '--' }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn v-else v-bind="col" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
<PersonalItemOperateDialog
v-model:visible="operateVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="handleSubmitted"
/>
<PersonalItemDetailDialog
v-model:visible="detailVisible"
:row-data="detailData"
:default-tab="detailDefaultTab"
@changed="handleDetailChanged"
/>
<PersonalItemBindExecutionDialog
v-model:visible="bindExecutionVisible"
:selected-count="selectedCount"
:submit-loading="bindExecutionSubmitting"
@submit="handleBindExecutionSubmit"
/>
<PersonalItemStatusActionDialog
v-model:visible="statusActionVisible"
:action="currentStatusAction"
@submit="handleStatusActionSubmit"
/>
</div>
</template>
<style scoped lang="scss">
.personal-item-row-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.personal-item-row-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.personal-item-row-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
:deep(.personal-item-title-link) {
max-width: 100%;
padding: 0;
vertical-align: baseline;
}
:deep(.personal-item-title-link > span) {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>