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

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