feat(personal-center): 重构个人事项详情并复用任务工作日志组件
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { ElButton, ElPopover } from 'element-plus';
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import type { Component, PropType } from 'vue';
|
||||
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export type BusinessTableAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
icon?: Component;
|
||||
disabled?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
};
|
||||
@@ -17,12 +18,20 @@ export default defineComponent({
|
||||
actions: {
|
||||
type: Array as PropType<BusinessTableAction[]>,
|
||||
required: true
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<'button' | 'icon'>,
|
||||
default: 'button'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
const directActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return props.actions;
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return props.actions;
|
||||
}
|
||||
@@ -31,6 +40,10 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const moreActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
@@ -47,21 +60,86 @@ export default defineComponent({
|
||||
await action.onClick();
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action => (
|
||||
function renderIcon(action: BusinessTableAction) {
|
||||
if (!action.icon) return null;
|
||||
|
||||
return h(action.icon, { class: 'business-table-action-icon' });
|
||||
}
|
||||
|
||||
function renderButtonAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIconAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
class="business-table-action-icon-button"
|
||||
aria-label={action.label}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
{renderIcon(action)}
|
||||
</ElButton>
|
||||
))}
|
||||
</ElTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMenuButton(action: BusinessTableAction) {
|
||||
if (props.variant === 'icon') {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__link"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
<span class="business-table-action-menu__item">
|
||||
{renderIcon(action)}
|
||||
<span>{action.label}</span>
|
||||
</span>
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action =>
|
||||
props.variant === 'icon' ? renderIconAction(action) : renderButtonAction(action)
|
||||
)}
|
||||
|
||||
{moreActions.value.length > 0 && (
|
||||
<ElPopover
|
||||
@@ -74,32 +152,28 @@ export default defineComponent({
|
||||
{{
|
||||
reference: () => (
|
||||
<ElButton
|
||||
plain
|
||||
link={props.variant === 'icon'}
|
||||
plain={props.variant !== 'icon'}
|
||||
size="small"
|
||||
class="business-table-action-button"
|
||||
class={
|
||||
props.variant === 'icon' ? 'business-table-action-icon-button' : 'business-table-action-button'
|
||||
}
|
||||
aria-label={$t('common.more')}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
{props.variant === 'icon' ? (
|
||||
<icon-mdi-dots-horizontal class="business-table-action-icon" />
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
)}
|
||||
</ElButton>
|
||||
),
|
||||
default: () => (
|
||||
<div class="business-table-action-menu">
|
||||
{moreActions.value.map(action => (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
))}
|
||||
{moreActions.value.map(action => renderMenuButton(action))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
const ensuredEmptyDictCodes = new Set<string>();
|
||||
|
||||
interface Props {
|
||||
dictCode: string;
|
||||
placeholder?: string;
|
||||
@@ -34,6 +37,7 @@ const model = defineModel<string | number | Array<string | number> | null | unde
|
||||
default: undefined
|
||||
});
|
||||
|
||||
const dictStore = useDictStore();
|
||||
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
||||
|
||||
const dictOptions = computed(() => {
|
||||
@@ -53,6 +57,19 @@ const selectedColorType = computed<string | null>(() => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.dictCode, dictOptions.value.length, dictStore.initialized, dictStore.loading] as const,
|
||||
async ([dictCode, optionCount, initialized, loading]) => {
|
||||
if (!dictCode || optionCount > 0 || !initialized || loading || ensuredEmptyDictCodes.has(dictCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensuredEmptyDictCodes.add(dictCode);
|
||||
await dictStore.ensureDictData(dictCode, true);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -92,9 +92,9 @@ export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_ob
|
||||
* 任务/个人事项类型字典编码
|
||||
*
|
||||
* 对应业务字段:任务、个人事项中的 type
|
||||
* 来源口径:用户明确指定任务/个人事项类型下拉来自运行时字典 rdms_task&item_type
|
||||
* 来源口径:用户明确指定任务/个人事项类型下拉来自运行时字典 rdms_task_item_type
|
||||
*/
|
||||
export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task&item_type';
|
||||
export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task_item_type';
|
||||
|
||||
/**
|
||||
* 需求允许删除的状态字典编码
|
||||
@@ -108,6 +108,6 @@ export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status'
|
||||
* 工作日志难度字典编码
|
||||
*
|
||||
* 对应业务字段:任务/个人事项工作日志中的 difficulty
|
||||
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task&item_worklog_difficulty
|
||||
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty
|
||||
*/
|
||||
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task&item_worklog_difficulty';
|
||||
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetFrontendDictCache } from '@/service/api';
|
||||
import { fetchGetDictDataByCode, fetchGetFrontendDictCache } from '@/service/api';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
|
||||
type DictValue = string | number | null | undefined;
|
||||
@@ -48,6 +48,17 @@ function normalizeFrontendDictData(
|
||||
return sortDictData(normalizedList);
|
||||
}
|
||||
|
||||
function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.Dict.DictData {
|
||||
return {
|
||||
...item,
|
||||
value: String(item.value),
|
||||
dictType: item.dictType || dictType,
|
||||
status: item.status ?? 0,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
remark: item.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
||||
const entries = Object.entries(cache);
|
||||
|
||||
@@ -99,6 +110,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
const loadedAt = ref<number | null>(null);
|
||||
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
|
||||
|
||||
function resetDictCache() {
|
||||
dictTypes.value = [];
|
||||
@@ -106,6 +118,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
loadedAt.value = null;
|
||||
initialized.value = false;
|
||||
initPromise = null;
|
||||
dictDataLoadPromises.clear();
|
||||
}
|
||||
|
||||
async function initDictCache(force = false) {
|
||||
@@ -147,6 +160,51 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function ensureDictData(dictType: string, force = false) {
|
||||
if (!dictType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
await initDictCache();
|
||||
}
|
||||
|
||||
if (!force && getDictData(dictType).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pending = dictDataLoadPromises.get(dictType);
|
||||
if (pending && !force) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await fetchGetDictDataByCode(dictType);
|
||||
|
||||
if (result.error || !result.data?.list?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dictDataMap.value = {
|
||||
...dictDataMap.value,
|
||||
[dictType]: sortDictData(result.data.list.map(item => normalizeDictDataItem(item, dictType)))
|
||||
};
|
||||
dictTypes.value = createRuntimeDictTypes(dictDataMap.value);
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
dictDataLoadPromises.set(dictType, promise);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (dictDataLoadPromises.get(dictType) === promise) {
|
||||
dictDataLoadPromises.delete(dictType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDictData(dictType: string, onlyEnabled = false) {
|
||||
if (!dictType) {
|
||||
return [];
|
||||
@@ -209,6 +267,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
dictDataMap,
|
||||
loadedAt,
|
||||
initDictCache,
|
||||
ensureDictData,
|
||||
resetDictCache,
|
||||
getDictData,
|
||||
getDictOptions,
|
||||
|
||||
@@ -416,6 +416,20 @@ html .el-collapse {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.business-table-action-icon-button {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
&.el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.business-table-action-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.business-table-action-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -428,6 +442,19 @@ html .el-collapse {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.business-table-action-menu__link {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0 !important;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.business-table-action-menu__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.business-table-card-body {
|
||||
display: flex;
|
||||
height: calc(100% - 56px);
|
||||
|
||||
4
src/typings/api/project.d.ts
vendored
4
src/typings/api/project.d.ts
vendored
@@ -412,7 +412,7 @@ declare namespace Api {
|
||||
durationHours: number;
|
||||
/** 本次填报进度(0~100,scale=2) */
|
||||
progressRate: number;
|
||||
/** 难度,来自字典 rdms_task&item_worklog_difficulty */
|
||||
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||
difficulty: string;
|
||||
/** 后端预留字段,目前始终为 null,前端按 difficulty + 字典 cache 自译 */
|
||||
difficultyName?: string | null;
|
||||
@@ -441,7 +441,7 @@ declare namespace Api {
|
||||
durationHours: number;
|
||||
/** 本次填报进度(0~100,scale=2,必填) */
|
||||
progressRate: number;
|
||||
/** 难度,来自字典 rdms_task&item_worklog_difficulty */
|
||||
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||
difficulty: string;
|
||||
workContent?: string | null;
|
||||
/** 编辑语义:null 保留原值 / [] 清空 / [...] 替换 */
|
||||
|
||||
@@ -17,6 +17,9 @@ import StateMachineOperateDialog from './modules/state-machine-operate-dialog.vu
|
||||
import StateMachineSearch from './modules/state-machine-search.vue';
|
||||
import StateTransitionDialog from './modules/state-transition-dialog.vue';
|
||||
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from './shared';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSourceBranch from '~icons/mdi/source-branch';
|
||||
|
||||
defineOptions({ name: 'StateMachineManage' });
|
||||
|
||||
@@ -71,6 +74,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'transition',
|
||||
label: '状态流转',
|
||||
icon: IconMdiSourceBranch,
|
||||
buttonType: 'primary',
|
||||
onClick: () => openTransitionDialog(row)
|
||||
});
|
||||
@@ -80,6 +84,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: IconMdiPencilOutline,
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
@@ -89,6 +94,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: IconMdiDeleteOutline,
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
});
|
||||
@@ -203,7 +209,7 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
|
||||
return <span>--</span>;
|
||||
}
|
||||
|
||||
return <BusinessTableActionCell actions={actions} />;
|
||||
return <BusinessTableActionCell actions={actions} variant="icon" />;
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -292,6 +292,13 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
openOperateDialog();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
tooltip: '删除',
|
||||
icon: markRaw(IconMdiDeleteOutline),
|
||||
type: 'danger',
|
||||
onClick: async () => handleDelete(row)
|
||||
},
|
||||
{
|
||||
key: 'status-pause',
|
||||
tooltip: pauseAction?.actionName ?? '暂停',
|
||||
@@ -305,19 +312,19 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
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
|
||||
})
|
||||
},
|
||||
// {
|
||||
// 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',
|
||||
@@ -331,13 +338,6 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
actionName: completeAction?.actionName ?? '完成',
|
||||
needReason: completeAction?.needReason ?? false
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
tooltip: '删除',
|
||||
icon: markRaw(IconMdiDeleteOutline),
|
||||
type: 'danger',
|
||||
onClick: async () => handleDelete(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchGetPersonalItemDetail } from '@/service/api';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
fetchCompletePersonalItem,
|
||||
fetchCreatePersonalItemWorklog,
|
||||
fetchDeletePersonalItemWorklog,
|
||||
fetchGetPersonalItemDetail,
|
||||
fetchGetPersonalItemWorklogPage,
|
||||
fetchUpdatePersonalItemWorklog
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import PersonalItemWorklogPanel from './personal-item-worklog-panel.vue';
|
||||
import TaskWorklogPanel from '@/views/project/project/execution/modules/task-worklog-panel.vue';
|
||||
import {
|
||||
formatPersonalItemDate,
|
||||
formatPersonalItemOwnerName,
|
||||
formatPersonalItemProgress,
|
||||
getPersonalItemStatusLabel,
|
||||
resolvePersonalItemStatusTagType
|
||||
} from './personal-item-shared';
|
||||
|
||||
defineOptions({ name: 'PersonalItemDetailDialog' });
|
||||
|
||||
@@ -27,6 +43,49 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const activeTab = ref<TabName>('worklog');
|
||||
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
|
||||
const authStore = useAuthStore();
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
const currentUserName = computed(
|
||||
() => authStore.userInfo.nickname?.trim() || authStore.userInfo.userName?.trim() || ''
|
||||
);
|
||||
|
||||
const COMPLETED_STATUS_CODE: Api.PersonalItem.PersonalItemStatusCode = 'completed';
|
||||
const COMPLETE_ACTION_CODE = 'complete';
|
||||
|
||||
const ownerName = computed(() => {
|
||||
if (!detailData.value) return '--';
|
||||
|
||||
const displayName = formatPersonalItemOwnerName(detailData.value);
|
||||
|
||||
if (displayName !== detailData.value.ownerId) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return detailData.value.ownerId === currentUserId.value && currentUserName.value
|
||||
? currentUserName.value
|
||||
: displayName;
|
||||
});
|
||||
const statusName = computed(() => (detailData.value ? getPersonalItemStatusLabel(detailData.value.statusCode) : '--'));
|
||||
const statusTagType = computed(() =>
|
||||
detailData.value ? resolvePersonalItemStatusTagType(detailData.value.statusCode) : 'info'
|
||||
);
|
||||
const progressText = computed(() => formatPersonalItemProgress(detailData.value?.progressRate));
|
||||
const plannedStartText = computed(() => formatPersonalItemDate(detailData.value?.plannedStartDate));
|
||||
const plannedEndText = computed(() => formatPersonalItemDate(detailData.value?.plannedEndDate));
|
||||
const actualStartText = computed(() => formatPersonalItemDate(detailData.value?.actualStartDate));
|
||||
const actualEndText = computed(() => formatPersonalItemDate(detailData.value?.actualEndDate));
|
||||
const totalHoursText = computed(() => {
|
||||
const total = detailData.value?.totalSpentHours;
|
||||
return `${typeof total === 'number' && Number.isFinite(total) ? total.toFixed(1) : '0.0'}h`;
|
||||
});
|
||||
const canSubmitWorklog = computed(() =>
|
||||
Boolean(
|
||||
detailData.value?.id &&
|
||||
(detailData.value.statusCode === 'pending' ||
|
||||
detailData.value.statusCode === 'active' ||
|
||||
detailData.value.statusCode === 'completed')
|
||||
)
|
||||
);
|
||||
|
||||
function syncDetailFromPageRow() {
|
||||
detailData.value = props.rowData ?? null;
|
||||
@@ -44,14 +103,64 @@ async function refreshDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function canPromptCompleteItem(item: Api.PersonalItem.PersonalItem) {
|
||||
if (item.statusCode === COMPLETED_STATUS_CODE || item.terminal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
item.progressRate >= 100 && (item.availableActions ?? []).some(action => action.actionCode === COMPLETE_ACTION_CODE)
|
||||
);
|
||||
}
|
||||
|
||||
async function promptCompleteItemIfNeeded() {
|
||||
if (!detailData.value || !canPromptCompleteItem(detailData.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('事项进度已达 100%,是否完成当前事项?', '完成确认', {
|
||||
confirmButtonText: '完成事项',
|
||||
cancelButtonText: '仅保留工时',
|
||||
type: 'info'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchCompletePersonalItem(detailData.value.id);
|
||||
|
||||
if (!error) {
|
||||
window.$message?.success('个人事项已完成');
|
||||
await refreshDetail();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWorklogChanged() {
|
||||
await refreshDetail();
|
||||
await promptCompleteItemIfNeeded();
|
||||
|
||||
if (detailData.value) {
|
||||
emit('changed', detailData.value);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPersonalWorklogPage(params: Api.Project.TaskWorklogSearchParams) {
|
||||
return fetchGetPersonalItemWorklogPage(detailData.value!.id, params);
|
||||
}
|
||||
|
||||
function createPersonalWorklog(data: Api.Project.SaveTaskWorklogParams) {
|
||||
return fetchCreatePersonalItemWorklog(detailData.value!.id, data);
|
||||
}
|
||||
|
||||
function updatePersonalWorklog(payload: { worklogId: string; data: Api.Project.SaveTaskWorklogParams }) {
|
||||
return fetchUpdatePersonalItemWorklog(detailData.value!.id, payload);
|
||||
}
|
||||
|
||||
function deletePersonalWorklog(worklogId: string) {
|
||||
return fetchDeletePersonalItemWorklog(detailData.value!.id, worklogId);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
@@ -92,12 +201,66 @@ watch(
|
||||
>
|
||||
<ElTabs v-model="activeTab" class="personal-item-detail-dialog__tabs">
|
||||
<ElTabPane label="工作日志" name="worklog" lazy>
|
||||
<PersonalItemWorklogPanel
|
||||
v-if="detailData"
|
||||
:item="detailData"
|
||||
:active="activeTab === 'worklog' && visible"
|
||||
@changed="handleWorklogChanged"
|
||||
/>
|
||||
<div v-if="detailData" class="personal-item-worklog-content">
|
||||
<div class="personal-item-worklog-content__cards">
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">负责人</span>
|
||||
<span class="personal-item-worklog-content__card-value" :title="ownerName">{{ ownerName }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">当前状态</span>
|
||||
<ElTag :type="statusTagType" size="small" effect="light" class="personal-item-worklog-content__card-tag">
|
||||
{{ statusName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">计划开始</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ plannedStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">计划结束</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ plannedEndText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">当前进度</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ progressText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">累计工时</span>
|
||||
<span class="personal-item-worklog-content__card-value personal-item-worklog-content__card-value--accent">
|
||||
{{ totalHoursText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">实际开始</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ actualStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">实际结束</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ actualEndText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskWorklogPanel
|
||||
project-id=""
|
||||
execution-id=""
|
||||
:task-id="detailData.id"
|
||||
:task-owner-id="currentUserId"
|
||||
:task-status-code="detailData.statusCode"
|
||||
:task-progress-rate="detailData.progressRate"
|
||||
:can-submit="canSubmitWorklog"
|
||||
:active="activeTab === 'worklog' && visible"
|
||||
:fetch-worklog-page="fetchPersonalWorklogPage"
|
||||
:create-worklog="createPersonalWorklog"
|
||||
:update-worklog="updatePersonalWorklog"
|
||||
:delete-worklog="deletePersonalWorklog"
|
||||
attachment-directory="personal-item-worklog"
|
||||
create-success-message="工作日志新增成功"
|
||||
update-success-message="工作日志修改成功"
|
||||
delete-success-message="工作日志删除成功"
|
||||
@changed="handleWorklogChanged"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</BusinessFormDialog>
|
||||
@@ -112,4 +275,51 @@ watch(
|
||||
.personal-item-detail-dialog__tabs :deep(.el-tab-pane) {
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-value {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-value--accent {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'PersonalItemWorklogFormDialog' });
|
||||
|
||||
type Mode = 'create' | 'edit' | 'view';
|
||||
type Granularity = 'day' | 'week';
|
||||
|
||||
interface Props {
|
||||
mode: Mode;
|
||||
rowData: Api.PersonalItem.PersonalItemWorklog | null;
|
||||
itemStatusCode: Api.PersonalItem.PersonalItemStatusCode;
|
||||
defaultProgressRate?: number;
|
||||
confirmLoading?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.PersonalItem.SavePersonalItemWorklogParams): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultProgressRate: 0,
|
||||
confirmLoading: false
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const isView = computed(() => props.mode === 'view');
|
||||
const isProgressReadonly = computed(() => isView.value || props.itemStatusCode === 'completed');
|
||||
|
||||
interface FormModel {
|
||||
granularity: Granularity;
|
||||
workDate: string | null;
|
||||
weekDate: Date | null;
|
||||
durationHours: number | null;
|
||||
progressRate: number;
|
||||
difficulty: string;
|
||||
workContent: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
}
|
||||
|
||||
const model = reactive<FormModel>({
|
||||
granularity: 'day',
|
||||
workDate: null,
|
||||
weekDate: null,
|
||||
durationHours: null,
|
||||
progressRate: 0,
|
||||
difficulty: '2',
|
||||
workContent: null,
|
||||
attachments: []
|
||||
});
|
||||
|
||||
const granularityOptions = [
|
||||
{ label: '按天', value: 'day' as const },
|
||||
{ label: '按周', value: 'week' as const }
|
||||
];
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.mode === 'create') return '填写工作日志';
|
||||
if (props.mode === 'view') return '查看工作日志';
|
||||
return '编辑工作日志';
|
||||
});
|
||||
const dateFieldLabel = computed(() => (model.granularity === 'day' ? '工作日期' : '工作周次'));
|
||||
|
||||
const workDateShortcuts = [
|
||||
{ text: '今天', value: () => new Date() },
|
||||
{ text: '昨天', value: () => dayjs().subtract(1, 'day').toDate() },
|
||||
{ text: '前天', value: () => dayjs().subtract(2, 'day').toDate() }
|
||||
];
|
||||
|
||||
const weekDateShortcuts = [
|
||||
{ text: '本周', value: () => dayjs().startOf('isoWeek').toDate() },
|
||||
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() }
|
||||
];
|
||||
|
||||
const weekRangeTooltip = computed(() => {
|
||||
if (!model.weekDate) return '';
|
||||
const start = dayjs(model.weekDate);
|
||||
if (!start.isValid()) return '';
|
||||
return `${start.format('YYYY-MM-DD')} ~ ${start.add(6, 'day').format('YYYY-MM-DD')}`;
|
||||
});
|
||||
|
||||
const rules = computed(
|
||||
() =>
|
||||
({
|
||||
granularity: [createRequiredRule('请选择填报粒度')],
|
||||
workDate: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule, value: string | null, callback) => {
|
||||
if (model.granularity !== 'day') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (!value) {
|
||||
callback(new Error('请选择工作日期'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
weekDate: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule, value: Date | null, callback) => {
|
||||
if (model.granularity !== 'week') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (!value) {
|
||||
callback(new Error('请选择工作周次'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
durationHours: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule, value: number | null, callback) => {
|
||||
if (value === null || value === undefined) {
|
||||
callback(new Error('请输入工时'));
|
||||
return;
|
||||
}
|
||||
if (value <= 0) {
|
||||
callback(new Error('工时必须大于 0'));
|
||||
return;
|
||||
}
|
||||
if (Math.round(value * 10) % 5 !== 0) {
|
||||
callback(new Error('工时必须是 0.5 小时的整数倍'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
progressRate: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule, value: number, callback) => {
|
||||
if (value < 0 || value > 100) {
|
||||
callback(new Error('进度需在 0 到 100 之间'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
difficulty: [createRequiredRule('请选择难度')],
|
||||
workContent: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule, value: string | null, callback) => {
|
||||
if (!value || !value.trim()) {
|
||||
callback(new Error('请输入工作内容'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}) satisfies Record<string, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
function detectGranularityFromRow(row: Api.PersonalItem.PersonalItemWorklog): Granularity {
|
||||
if (row.startDate === row.endDate) {
|
||||
return 'day';
|
||||
}
|
||||
|
||||
const start = dayjs(row.startDate);
|
||||
const end = dayjs(row.endDate);
|
||||
|
||||
if (start.isoWeekday() === 1 && end.isoWeekday() === 7 && end.diff(start, 'day') === 6) {
|
||||
return 'week';
|
||||
}
|
||||
|
||||
return 'day';
|
||||
}
|
||||
|
||||
function getStartEndFromModel(): { startDate: string; endDate: string } {
|
||||
if (model.granularity === 'day') {
|
||||
return {
|
||||
startDate: model.workDate!,
|
||||
endDate: model.workDate!
|
||||
};
|
||||
}
|
||||
|
||||
const weekStart = dayjs(model.weekDate!).startOf('isoWeek');
|
||||
return {
|
||||
startDate: weekStart.format('YYYY-MM-DD'),
|
||||
endDate: weekStart.add(6, 'day').format('YYYY-MM-DD')
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.granularity,
|
||||
() => {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
|
||||
async function handleConfirm() {
|
||||
if (isView.value) {
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const { startDate, endDate } = getStartEndFromModel();
|
||||
|
||||
const payload: Api.PersonalItem.SavePersonalItemWorklogParams = {
|
||||
startDate,
|
||||
endDate,
|
||||
durationHours: Number(model.durationHours!.toFixed(1)),
|
||||
progressRate: Number(model.progressRate.toFixed(2)),
|
||||
difficulty: model.difficulty,
|
||||
workContent: model.workContent?.trim() || null,
|
||||
attachments: [...model.attachments]
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = props.rowData;
|
||||
if (row) {
|
||||
const granularity = detectGranularityFromRow(row);
|
||||
model.granularity = granularity;
|
||||
model.workDate = granularity === 'day' ? row.startDate : null;
|
||||
model.weekDate = granularity === 'week' ? dayjs(row.startDate).toDate() : null;
|
||||
model.durationHours = row.durationHours;
|
||||
model.progressRate = row.progressRate;
|
||||
model.difficulty = row.difficulty || '2';
|
||||
model.workContent = row.workContent || null;
|
||||
model.attachments = row.attachments ? [...row.attachments] : [];
|
||||
} else {
|
||||
model.granularity = 'day';
|
||||
model.workDate = dayjs().format('YYYY-MM-DD');
|
||||
model.weekDate = null;
|
||||
model.durationHours = null;
|
||||
model.progressRate = props.defaultProgressRate;
|
||||
model.difficulty = '2';
|
||||
model.workContent = null;
|
||||
model.attachments = [];
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
async commit() {
|
||||
await attachmentUploaderRef.value?.commit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="md"
|
||||
:confirm-loading="props.confirmLoading"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="填报粒度" prop="granularity">
|
||||
<ElSegmented v-model="model.granularity" :options="granularityOptions" :disabled="isView" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="dateFieldLabel" :prop="model.granularity === 'day' ? 'workDate' : 'weekDate'">
|
||||
<ElDatePicker
|
||||
v-if="model.granularity === 'day'"
|
||||
v-model="model.workDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择工作日期"
|
||||
:shortcuts="isView ? undefined : workDateShortcuts"
|
||||
:disabled="isView"
|
||||
class="personal-item-worklog-form-dialog__date-picker"
|
||||
/>
|
||||
<ElTooltip v-else :content="weekRangeTooltip" :disabled="!weekRangeTooltip" placement="top">
|
||||
<span class="personal-item-worklog-form-dialog__week-wrapper">
|
||||
<ElDatePicker
|
||||
v-model="model.weekDate"
|
||||
type="week"
|
||||
format="YYYY[年第]ww[周]"
|
||||
placeholder="选择工作周次"
|
||||
:shortcuts="isView ? undefined : weekDateShortcuts"
|
||||
:disabled="isView"
|
||||
class="personal-item-worklog-form-dialog__date-picker"
|
||||
/>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="工时(小时)" prop="durationHours">
|
||||
<ElInputNumber
|
||||
v-model="model.durationHours"
|
||||
:min="0.5"
|
||||
:step="0.5"
|
||||
:precision="1"
|
||||
:disabled="isView"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="进度(%)" prop="progressRate">
|
||||
<ElInputNumber
|
||||
v-model="model.progressRate"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:precision="2"
|
||||
:disabled="isProgressReadonly"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="难度" prop="difficulty">
|
||||
<DictSelect
|
||||
v-model="model.difficulty"
|
||||
:dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE"
|
||||
:disabled="isView"
|
||||
:clearable="false"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="工作内容" prop="workContent">
|
||||
<ElInput
|
||||
v-model="model.workContent"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
:maxlength="isView ? undefined : 2000"
|
||||
:show-word-limit="!isView"
|
||||
:disabled="isView"
|
||||
placeholder="简述本次填报的工作内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="附件">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
:disabled="isView"
|
||||
directory="personal-item-worklog"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
|
||||
<template v-if="isView" #footer="{ close }">
|
||||
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||
</template>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.personal-item-worklog-form-dialog__date-picker.el-date-editor.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.personal-item-worklog-form-dialog__week-wrapper {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,708 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessageBox, ElPopconfirm, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
|
||||
import {
|
||||
fetchCompletePersonalItem,
|
||||
fetchCreatePersonalItemWorklog,
|
||||
fetchDeletePersonalItemWorklog,
|
||||
fetchGetPersonalItemDetail,
|
||||
fetchGetPersonalItemWorklogPage,
|
||||
fetchUpdatePersonalItemWorklog
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import {
|
||||
formatPersonalItemDate,
|
||||
formatPersonalItemOwnerName,
|
||||
formatPersonalItemProgress,
|
||||
getPersonalItemStatusLabel,
|
||||
resolvePersonalItemStatusTagType
|
||||
} from './personal-item-shared';
|
||||
import PersonalItemWorklogFormDialog from './personal-item-worklog-form-dialog.vue';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiPaperclip from '~icons/mdi/paperclip';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'PersonalItemWorklogPanel' });
|
||||
|
||||
type WorklogGranularity = 'day' | 'week';
|
||||
|
||||
interface Props {
|
||||
item: Api.PersonalItem.PersonalItem;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
active: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
}>();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { getLabel: getDifficultyLabel } = useDict(RDMS_WORKLOG_DIFFICULTY_DICT_CODE);
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
const currentUserName = computed(
|
||||
() => authStore.userInfo.nickname?.trim() || authStore.userInfo.userName?.trim() || ''
|
||||
);
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const TABLE_HEIGHT = 390;
|
||||
const COMPLETED_STATUS_CODE: Api.PersonalItem.PersonalItemStatusCode = 'completed';
|
||||
const COMPLETE_ACTION_CODE = 'complete';
|
||||
|
||||
const pageNo = ref(1);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const records = ref<Api.PersonalItem.PersonalItemWorklog[]>([]);
|
||||
|
||||
const formVisible = ref(false);
|
||||
const formMode = ref<'create' | 'edit' | 'view'>('create');
|
||||
const submitting = ref(false);
|
||||
const editingWorklog = ref<Api.PersonalItem.PersonalItemWorklog | null>(null);
|
||||
const worklogFormDialogRef = ref<InstanceType<typeof PersonalItemWorklogFormDialog> | null>(null);
|
||||
|
||||
const totalHours = computed(() => {
|
||||
if (typeof props.item.totalSpentHours === 'number' && Number.isFinite(props.item.totalSpentHours)) {
|
||||
return props.item.totalSpentHours;
|
||||
}
|
||||
|
||||
return records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0);
|
||||
});
|
||||
const totalHoursText = computed(() => `${totalHours.value.toFixed(1)}h`);
|
||||
const ownerName = computed(() => {
|
||||
const displayName = formatPersonalItemOwnerName(props.item);
|
||||
|
||||
if (displayName !== props.item.ownerId) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return props.item.ownerId === currentUserId.value && currentUserName.value ? currentUserName.value : displayName;
|
||||
});
|
||||
const statusName = computed(() => getPersonalItemStatusLabel(props.item.statusCode));
|
||||
const progressText = computed(() => formatPersonalItemProgress(props.item.progressRate));
|
||||
const plannedStartText = computed(() => formatPersonalItemDate(props.item.plannedStartDate));
|
||||
const plannedEndText = computed(() => formatPersonalItemDate(props.item.plannedEndDate));
|
||||
const actualStartText = computed(() => formatPersonalItemDate(props.item.actualStartDate));
|
||||
const actualEndText = computed(() => formatPersonalItemDate(props.item.actualEndDate));
|
||||
|
||||
const list = computed(() => records.value);
|
||||
|
||||
const canCreate = computed(() =>
|
||||
Boolean(
|
||||
props.item.id &&
|
||||
(props.item.statusCode === 'pending' ||
|
||||
props.item.statusCode === 'active' ||
|
||||
props.item.statusCode === 'completed')
|
||||
)
|
||||
);
|
||||
|
||||
const isWorklogMutableStatus = computed(
|
||||
() => props.item.statusCode === 'active' || props.item.statusCode === 'completed'
|
||||
);
|
||||
|
||||
function getRowIndex(index: number) {
|
||||
return (pageNo.value - 1) * PAGE_SIZE + index + 1;
|
||||
}
|
||||
|
||||
function formatHours(hours: number | null | undefined) {
|
||||
if (typeof hours !== 'number' || !Number.isFinite(hours)) {
|
||||
return '0h';
|
||||
}
|
||||
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatWorklogPeriod(startDate: string | null | undefined, endDate: string | null | undefined) {
|
||||
if (!startDate || !endDate) {
|
||||
return {
|
||||
granularity: null as WorklogGranularity | null,
|
||||
display: '--',
|
||||
tooltip: null as string | null
|
||||
};
|
||||
}
|
||||
|
||||
const startKey = formatPersonalItemDate(startDate);
|
||||
const endKey = formatPersonalItemDate(endDate);
|
||||
|
||||
if (startKey === endKey) {
|
||||
const current = dayjs(startDate);
|
||||
const weekSuffix = current.isValid() ? `(第${current.isoWeek()}周)` : '';
|
||||
|
||||
return {
|
||||
granularity: 'day' as const,
|
||||
display: `${startKey}${weekSuffix}`,
|
||||
tooltip: null
|
||||
};
|
||||
}
|
||||
|
||||
const start = dayjs(startDate);
|
||||
|
||||
return {
|
||||
granularity: 'week' as const,
|
||||
display: start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : `${startKey} ~ ${endKey}`,
|
||||
tooltip: `${startKey} ~ ${endKey}`
|
||||
};
|
||||
}
|
||||
|
||||
function getWorklogGranularityName(granularity: WorklogGranularity | null) {
|
||||
if (granularity === 'day') {
|
||||
return '日';
|
||||
}
|
||||
|
||||
if (granularity === 'week') {
|
||||
return '周';
|
||||
}
|
||||
|
||||
return '--';
|
||||
}
|
||||
|
||||
function canEditRow(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
|
||||
}
|
||||
|
||||
function canDeleteRow(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
if (!props.item.id || !props.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchGetPersonalItemWorklogPage(props.item.id, {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: PAGE_SIZE
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
records.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
records.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
function canPromptCompleteItem(item: Api.PersonalItem.PersonalItem) {
|
||||
if (item.statusCode === COMPLETED_STATUS_CODE || item.terminal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
item.progressRate >= 100 && (item.availableActions ?? []).some(action => action.actionCode === COMPLETE_ACTION_CODE)
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchLatestItem() {
|
||||
const { error, data } = await fetchGetPersonalItemDetail(props.item.id);
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function promptCompleteItemIfNeeded() {
|
||||
const latestItem = await fetchLatestItem();
|
||||
|
||||
if (!latestItem || !canPromptCompleteItem(latestItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('事项进度已达 100%,是否完成当前事项?', '完成确认', {
|
||||
confirmButtonText: '完成事项',
|
||||
cancelButtonText: '仅保留工时',
|
||||
type: 'info'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchCompletePersonalItem(latestItem.id);
|
||||
|
||||
if (!error) {
|
||||
window.$message?.success('个人事项已完成');
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadAfterWorklogChanged() {
|
||||
await loadRecords();
|
||||
await promptCompleteItemIfNeeded();
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pageNo.value = page;
|
||||
loadRecords();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
formMode.value = 'create';
|
||||
editingWorklog.value = null;
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
function openView(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
formMode.value = 'view';
|
||||
editingWorklog.value = row;
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
formMode.value = 'edit';
|
||||
editingWorklog.value = row;
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
const shouldStepBack = records.value.length === 1 && pageNo.value > 1;
|
||||
const { error } = await fetchDeletePersonalItemWorklog(props.item.id, row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStepBack) {
|
||||
pageNo.value -= 1;
|
||||
}
|
||||
|
||||
window.$message?.success('工作日志删除成功');
|
||||
await reloadAfterWorklogChanged();
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: Api.PersonalItem.SavePersonalItemWorklogParams) {
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
const result =
|
||||
formMode.value === 'edit' && editingWorklog.value
|
||||
? await fetchUpdatePersonalItemWorklog(props.item.id, {
|
||||
worklogId: editingWorklog.value.id,
|
||||
data: payload
|
||||
})
|
||||
: await fetchCreatePersonalItemWorklog(props.item.id, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await worklogFormDialogRef.value?.commit();
|
||||
|
||||
window.$message?.success(formMode.value === 'edit' ? '工作日志修改成功' : '工作日志新增成功');
|
||||
formVisible.value = false;
|
||||
await reloadAfterWorklogChanged();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(total, value => {
|
||||
const maxPage = Math.max(1, Math.ceil(value / PAGE_SIZE));
|
||||
if (pageNo.value > maxPage) {
|
||||
pageNo.value = maxPage;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.item.id, () => props.active],
|
||||
([itemId, active]) => {
|
||||
if (!itemId) {
|
||||
records.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
pageNo.value = 1;
|
||||
|
||||
if (active) {
|
||||
loadRecords();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="personal-item-worklog-panel">
|
||||
<div class="personal-item-worklog-panel__cards">
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">负责人</span>
|
||||
<span class="personal-item-worklog-panel__card-value" :title="ownerName">{{ ownerName }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">当前状态</span>
|
||||
<ElTag
|
||||
:type="resolvePersonalItemStatusTagType(props.item.statusCode)"
|
||||
size="small"
|
||||
effect="light"
|
||||
class="personal-item-worklog-panel__card-tag"
|
||||
>
|
||||
{{ statusName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">计划开始</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ plannedStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">计划结束</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ plannedEndText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">当前进度</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ progressText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">累计工时</span>
|
||||
<span class="personal-item-worklog-panel__card-value personal-item-worklog-panel__card-value--accent">
|
||||
{{ totalHoursText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">实际开始</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ actualStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">实际结束</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ actualEndText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header v-if="canCreate" class="personal-item-worklog-panel__header">
|
||||
<ElButton type="primary" size="small" :icon="Plus" @click="openCreate">填报</ElButton>
|
||||
</header>
|
||||
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
:height="TABLE_HEIGHT"
|
||||
border
|
||||
empty-text="暂无工作日志"
|
||||
class="personal-item-worklog-panel__table"
|
||||
>
|
||||
<ElTableColumn type="index" :index="getRowIndex" label="序号" width="60" align="center" />
|
||||
<ElTableColumn label="粒度" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag
|
||||
:type="formatWorklogPeriod(row.startDate, row.endDate).granularity === 'week' ? 'warning' : 'info'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ getWorklogGranularityName(formatWorklogPeriod(row.startDate, row.endDate).granularity) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="日期" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTooltip
|
||||
v-if="formatWorklogPeriod(row.startDate, row.endDate).tooltip"
|
||||
:content="formatWorklogPeriod(row.startDate, row.endDate).tooltip ?? ''"
|
||||
placement="top"
|
||||
>
|
||||
<span>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
|
||||
</ElTooltip>
|
||||
<span v-else>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="工作内容" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<ElPopover
|
||||
v-if="row.workContent || (row.attachments && row.attachments.length)"
|
||||
trigger="hover"
|
||||
placement="top"
|
||||
:width="360"
|
||||
:show-after="200"
|
||||
popper-class="personal-item-worklog-panel__content-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="personal-item-worklog-panel__content-cell">
|
||||
{{ row.workContent || `附件 ${row.attachments?.length ?? 0} 个` }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="personal-item-worklog-panel__content-card">
|
||||
<div class="personal-item-worklog-panel__content-card-header">
|
||||
<span>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
|
||||
<span class="personal-item-worklog-panel__content-card-meta">
|
||||
{{ formatHours(row.durationHours) }} / {{ formatPersonalItemProgress(row.progressRate) }} /
|
||||
{{ getDifficultyLabel(row.difficulty, { fallback: '--' }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.workContent" class="personal-item-worklog-panel__content-card-body">
|
||||
{{ row.workContent }}
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__content-card-attachments">
|
||||
<div class="personal-item-worklog-panel__content-card-section-title">
|
||||
<ElIcon><IconMdiPaperclip /></ElIcon>
|
||||
<span v-if="row.attachments && row.attachments.length">附件({{ row.attachments.length }})</span>
|
||||
<span v-else class="personal-item-worklog-panel__content-card-attachment-empty">无附件</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="row.attachments && row.attachments.length"
|
||||
class="personal-item-worklog-panel__content-card-attachments-scroll"
|
||||
>
|
||||
<BusinessAttachmentUploader :model-value="row.attachments" disabled flat />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<span v-else class="personal-item-worklog-panel__content-cell-empty">--</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="时长" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="personal-item-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="进度" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="personal-item-worklog-panel__progress">{{ formatPersonalItemProgress(row.progressRate) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="personal-item-worklog-panel__actions" @click.stop>
|
||||
<ElTooltip content="查看">
|
||||
<ElButton link type="primary" class="personal-item-worklog-panel__action-btn" @click="openView(row)">
|
||||
<IconMdiEyeOutline class="text-15px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip :content="canEditRow(row) ? '编辑' : '仅可编辑本人填报'">
|
||||
<span class="inline-flex">
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
class="personal-item-worklog-panel__action-btn"
|
||||
:disabled="!canEditRow(row)"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
<IconMdiPencilOutline class="text-15px" />
|
||||
</ElButton>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm
|
||||
v-if="canDeleteRow(row)"
|
||||
title="确认删除该条工作日志?"
|
||||
confirm-button-text="删除"
|
||||
cancel-button-text="取消"
|
||||
confirm-button-type="danger"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="inline-flex">
|
||||
<ElTooltip content="删除">
|
||||
<ElButton link type="danger" class="personal-item-worklog-panel__action-btn">
|
||||
<IconMdiDeleteOutline class="text-15px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
<ElTooltip v-else content="仅可删除本人填报">
|
||||
<span class="inline-flex">
|
||||
<ElButton link type="danger" class="personal-item-worklog-panel__action-btn" disabled>
|
||||
<IconMdiDeleteOutline class="text-15px" />
|
||||
</ElButton>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div class="personal-item-worklog-panel__pagination">
|
||||
<ElPagination
|
||||
v-if="total > 0"
|
||||
small
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:current-page="pageNo"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PersonalItemWorklogFormDialog
|
||||
ref="worklogFormDialogRef"
|
||||
v-model:visible="formVisible"
|
||||
:mode="formMode"
|
||||
:row-data="editingWorklog"
|
||||
:item-status-code="props.item.statusCode"
|
||||
:default-progress-rate="props.item.progressRate"
|
||||
:confirm-loading="submitting"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.personal-item-worklog-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card-value--accent {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__duration {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__progress {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:deep(.personal-item-worklog-panel__action-btn) {
|
||||
padding: 3px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-cell {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-cell-empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-meta {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-attachments-scroll {
|
||||
max-height: 144px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-section-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-attachment-empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,8 @@ interface Props {
|
||||
taskStatusCode: string;
|
||||
/** 创建模式下的进度兜底默认值(owner 路径会传 task.progressRate) */
|
||||
defaultOwnerProgressRate?: number;
|
||||
/** 附件上传目录;任务默认 task-worklog,复用方可按业务域覆盖 */
|
||||
attachmentDirectory?: string;
|
||||
/** 提交中(HTTP 进行中),由父组件控制 */
|
||||
confirmLoading?: boolean;
|
||||
}
|
||||
@@ -35,6 +37,7 @@ interface Emits {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultOwnerProgressRate: 0,
|
||||
attachmentDirectory: 'task-worklog',
|
||||
confirmLoading: false
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -62,7 +65,7 @@ interface FormModel {
|
||||
/** 0.5 颗粒小时数 */
|
||||
durationHours: number | null;
|
||||
progressRate: number;
|
||||
/** 完成难度,字典 rdms_worklog_difficulty 的 value,默认 "2";用户清空后为 null,由 required 校验拦截 */
|
||||
/** 完成难度,字典 rdms_task_item_worklog_difficulty 的 value,默认 "2";用户清空后为 null,由 required 校验拦截 */
|
||||
difficulty: string | null;
|
||||
workContent: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
@@ -458,7 +461,7 @@ defineExpose({
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
:disabled="isView"
|
||||
directory="task-worklog"
|
||||
:directory="props.attachmentDirectory"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchGetProjectTaskWorklogPage,
|
||||
fetchUpdateProjectTaskWorklog
|
||||
} from '@/service/api/project';
|
||||
import type { ServiceRequestResult } from '@/service/api/shared';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
@@ -23,6 +24,15 @@ import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'ProjectExecutionTaskWorklogPanel' });
|
||||
|
||||
type WorklogPageResult = {
|
||||
error: unknown;
|
||||
data?: Api.Project.PageResult<Api.Project.TaskWorklog> | null;
|
||||
};
|
||||
|
||||
type WorklogMutationResult = {
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
executionId: string;
|
||||
@@ -42,6 +52,24 @@ interface Props {
|
||||
ownerNickname?: string | null;
|
||||
/** 是否展示「填报人」列与列头筛选;只在 owner 视角下展示 */
|
||||
showAssigneeColumn?: boolean;
|
||||
/** 是否激活;非激活时不主动加载内部数据 */
|
||||
active?: boolean;
|
||||
/** 自定义分页查询,个人事项等复用方通过它接入自己的接口 */
|
||||
fetchWorklogPage?: (params: Api.Project.TaskWorklogSearchParams) => Promise<WorklogPageResult>;
|
||||
/** 自定义新增接口 */
|
||||
createWorklog?: (data: Api.Project.SaveTaskWorklogParams) => Promise<WorklogMutationResult>;
|
||||
/** 自定义编辑接口 */
|
||||
updateWorklog?: (payload: {
|
||||
worklogId: string;
|
||||
data: Api.Project.SaveTaskWorklogParams;
|
||||
}) => Promise<WorklogMutationResult>;
|
||||
/** 自定义删除接口 */
|
||||
deleteWorklog?: (worklogId: string) => Promise<WorklogMutationResult>;
|
||||
/** 附件上传目录 */
|
||||
attachmentDirectory?: string;
|
||||
createSuccessMessage?: string;
|
||||
updateSuccessMessage?: string;
|
||||
deleteSuccessMessage?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -50,7 +78,12 @@ interface Emits {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
taskProgressRate: 0,
|
||||
showAssigneeColumn: false
|
||||
showAssigneeColumn: false,
|
||||
active: true,
|
||||
attachmentDirectory: 'task-worklog',
|
||||
createSuccessMessage: '填报成功',
|
||||
updateSuccessMessage: '填报已更新',
|
||||
deleteSuccessMessage: '工时已删除'
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
@@ -249,7 +282,19 @@ async function loadList() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.projectId || !props.executionId || !props.taskId) {
|
||||
if (!props.fetchWorklogPage) {
|
||||
internalList.value = [];
|
||||
internalTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.taskId) {
|
||||
internalList.value = [];
|
||||
internalTotal.value = 0;
|
||||
return;
|
||||
@@ -270,12 +315,9 @@ async function loadList() {
|
||||
params.difficulty = difficultyFilter.value;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectTaskWorklogPage(
|
||||
props.projectId,
|
||||
props.executionId,
|
||||
props.taskId,
|
||||
params
|
||||
);
|
||||
const { error, data } = props.fetchWorklogPage
|
||||
? await props.fetchWorklogPage(params)
|
||||
: await fetchGetProjectTaskWorklogPage(props.projectId, props.executionId, props.taskId, params);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
@@ -318,19 +360,33 @@ function handleView(row: Api.Project.TaskWorklog) {
|
||||
async function handleSubmit(payload: Api.Project.SaveTaskWorklogParams) {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result =
|
||||
formMode.value === 'create'
|
||||
? await fetchCreateProjectTaskWorklog(props.projectId, props.executionId, props.taskId, payload)
|
||||
: await fetchUpdateProjectTaskWorklog(props.projectId, props.executionId, props.taskId, {
|
||||
worklogId: editingWorklog.value!.id,
|
||||
data: payload
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let result: any;
|
||||
|
||||
if (formMode.value === 'create') {
|
||||
if (props.createWorklog) {
|
||||
result = await props.createWorklog(payload);
|
||||
} else {
|
||||
result = await fetchCreateProjectTaskWorklog(props.projectId, props.executionId, props.taskId, payload);
|
||||
}
|
||||
} else {
|
||||
const updatePayload = {
|
||||
worklogId: editingWorklog.value!.id,
|
||||
data: payload
|
||||
};
|
||||
|
||||
if (props.updateWorklog) {
|
||||
result = await props.updateWorklog(updatePayload);
|
||||
} else {
|
||||
result = await fetchUpdateProjectTaskWorklog(props.projectId, props.executionId, props.taskId, updatePayload);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(formMode.value === 'create' ? '填报成功' : '填报已更新');
|
||||
window.$message?.success(formMode.value === 'create' ? props.createSuccessMessage : props.updateSuccessMessage);
|
||||
// 业务保存成功才 commit:删除用户在弹层里标记删除的附件
|
||||
await worklogFormDialogRef.value?.commit();
|
||||
formVisible.value = false;
|
||||
@@ -351,12 +407,14 @@ async function handleSubmit(payload: Api.Project.SaveTaskWorklogParams) {
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.Project.TaskWorklog) {
|
||||
const { error } = await fetchDeleteProjectTaskWorklog(props.projectId, props.executionId, props.taskId, row.id);
|
||||
const { error } = props.deleteWorklog
|
||||
? await props.deleteWorklog(row.id)
|
||||
: await fetchDeleteProjectTaskWorklog(props.projectId, props.executionId, props.taskId, row.id);
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('工时已删除');
|
||||
window.$message?.success(props.deleteSuccessMessage);
|
||||
if (!usingExternal.value) {
|
||||
// 删完最后一页若空了,回退一页(仅 server-side 路径手动处理;外部模式下由 watch(total) 兜底)
|
||||
if (list.value.length === 1 && pageNo.value > 1) {
|
||||
@@ -391,14 +449,16 @@ watch(difficultyFilter, () => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.taskId,
|
||||
() => {
|
||||
() => [props.active, props.taskId] as const,
|
||||
([active]) => {
|
||||
pageNo.value = 1;
|
||||
userFilter.value = [];
|
||||
userFilterPopoverVisible.value = false;
|
||||
difficultyFilter.value = '';
|
||||
difficultyFilterPopoverVisible.value = false;
|
||||
loadList();
|
||||
if (active) {
|
||||
loadList();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -694,6 +754,7 @@ watch(
|
||||
:task-owner-id="taskOwnerId"
|
||||
:task-status-code="taskStatusCode"
|
||||
:default-owner-progress-rate="taskProgressRate"
|
||||
:attachment-directory="props.attachmentDirectory"
|
||||
:confirm-loading="submitting"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user