feat(personal-center): 重构个人事项详情并复用任务工作日志组件

This commit is contained in:
caozehui
2026-05-22 10:46:46 +08:00
parent 62859bfc38
commit ab882e085b
13 changed files with 547 additions and 1207 deletions

View File

@@ -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>
)
}}

View File

@@ -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>

View File

@@ -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';

View File

@@ -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,

View File

@@ -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);

View File

@@ -412,7 +412,7 @@ declare namespace Api {
durationHours: number;
/** 本次填报进度0~100scale=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~100scale=2必填 */
progressRate: number;
/** 难度,来自字典 rdms_task&item_worklog_difficulty */
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
difficulty: string;
workContent?: string | null;
/** 编辑语义null 保留原值 / [] 清空 / [...] 替换 */

View File

@@ -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" />;
}
}
]

View File

@@ -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)
}
];
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>