feat(personal-item): 个人事项

This commit is contained in:
caozehui
2026-05-19 10:59:07 +08:00
parent acd41555f9
commit 7d578ab271
28 changed files with 3743 additions and 105 deletions

View File

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

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { fetchGetPersonalItemExecutionOptions } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PersonalItemBindExecutionDialog' });
interface Props {
selectedCount: number;
submitLoading?: boolean;
}
interface Emits {
(e: 'submit', payload: { executionId: string }): void;
}
const props = withDefaults(defineProps<Props>(), {
submitLoading: false
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const loading = ref(false);
const executionOptions = ref<Api.PersonalItem.PersonalItemExecutionOption[]>([]);
const model = reactive({
executionId: ''
});
const rules = computed(
() =>
({
executionId: [createRequiredRule('请选择执行')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function getExecutionOptionLabel(option: Api.PersonalItem.PersonalItemExecutionOption) {
if (option.projectName?.trim()) {
return `${option.projectName} / ${option.executionName}`;
}
return option.executionName;
}
async function loadExecutionOptions() {
loading.value = true;
const { error, data } = await fetchGetPersonalItemExecutionOptions();
loading.value = false;
if (error || !data) {
executionOptions.value = [];
return;
}
executionOptions.value = data.map(item => ({ ...item }));
}
async function initDialog() {
model.executionId = '';
await loadExecutionOptions();
formRef.value?.clearValidate();
}
async function handleConfirm() {
await validate();
emit('submit', {
executionId: model.executionId
});
}
watch(
() => visible.value,
value => {
if (value) {
initDialog();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="批量关联执行"
preset="sm"
:loading="loading"
:confirm-loading="props.submitLoading"
@confirm="handleConfirm"
>
<ElAlert
:title="`已选中 ${props.selectedCount} 条个人事项,关联成功后这些事项会从当前列表移除。`"
type="info"
:closable="false"
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem label="执行" prop="executionId">
<ElSelect
v-model="model.executionId"
clearable
filterable
placeholder="请选择执行"
class="w-full"
:loading="loading"
>
<ElOption
v-for="option in executionOptions"
:key="option.executionId"
:label="getExecutionOptionLabel(option)"
:value="option.executionId"
/>
</ElSelect>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { fetchGetPersonalItemDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import PersonalItemWorklogPanel from './personal-item-worklog-panel.vue';
defineOptions({ name: 'PersonalItemDetailDialog' });
type TabName = 'worklog';
interface Props {
rowData?: Api.PersonalItem.PersonalItem | null;
defaultTab?: TabName;
}
const props = withDefaults(defineProps<Props>(), {
defaultTab: 'worklog'
});
const emit = defineEmits<{
changed: [item: Api.PersonalItem.PersonalItem];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const activeTab = ref<TabName>('worklog');
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
function syncDetailFromPageRow() {
detailData.value = props.rowData ?? null;
}
async function refreshDetail() {
if (!detailData.value?.id) {
return;
}
const { error, data } = await fetchGetPersonalItemDetail(detailData.value.id);
if (!error && data) {
detailData.value = data;
}
}
async function handleWorklogChanged() {
await refreshDetail();
if (detailData.value) {
emit('changed', detailData.value);
}
}
watch(
() => visible.value,
value => {
if (value) {
activeTab.value = props.defaultTab;
syncDetailFromPageRow();
}
}
);
watch(
() => props.rowData,
() => {
if (visible.value) {
syncDetailFromPageRow();
}
}
);
watch(
() => props.defaultTab,
value => {
if (visible.value) {
activeTab.value = value;
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="工作日志"
width="1100px"
max-body-height="78vh"
:show-footer="false"
:scrollbar="false"
>
<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"
/>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.personal-item-detail-dialog__tabs {
--el-tabs-header-height: 40px;
}
.personal-item-detail-dialog__tabs :deep(.el-tabs__content),
.personal-item-detail-dialog__tabs :deep(.el-tab-pane) {
min-height: 640px;
}
</style>

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { fetchCreatePersonalItem, fetchGetPersonalItemDetail, fetchUpdatePersonalItem } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
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';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import { isEmptyRichText } from './personal-item-shared';
defineOptions({ name: 'PersonalItemOperateDialog' });
type PersonalItemOperateType = UI.TableOperateType | 'view';
interface Props {
operateType: PersonalItemOperateType;
rowData?: Api.PersonalItem.PersonalItem | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || 'current-user');
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const h = entries[0]?.contentRect.height;
if (h && h > 120) {
editorHeight.value = `${Math.max(h - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
const isEdit = computed(() => props.operateType === 'edit');
const isView = computed(() => props.operateType === 'view');
const detailLoading = ref(false);
const submitting = ref(false);
interface Model {
taskTitle: string;
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
attachments: Api.Project.AttachmentItem[];
}
const model = reactive<Model>(createDefaultModel());
const title = computed(() => {
if (isView.value) {
return '个人事项详情';
}
return isEdit.value ? '编辑个人事项' : '新增个人事项';
});
function createDefaultModel(): Model {
return {
taskTitle: '',
plannedStartDate: null,
plannedEndDate: null,
taskDesc: null,
attachments: []
};
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
const rules = computed(
() =>
({
taskTitle: [
createRequiredRule('请输入事项标题'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入事项标题'));
return;
}
callback();
},
trigger: 'blur'
}
],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(model.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
callback();
},
trigger: 'change'
}
]
}) satisfies Record<string, App.Global.FormRule[]>
);
async function initModel() {
detailLoading.value = true;
Object.assign(model, createDefaultModel());
if ((isEdit.value || isView.value) && props.rowData) {
const { error, data } = await fetchGetPersonalItemDetail(props.rowData.id);
if (!error && data) {
model.taskTitle = data.taskTitle;
model.plannedStartDate = data.plannedStartDate;
model.plannedEndDate = data.plannedEndDate;
model.taskDesc = data.taskDesc;
model.attachments = data.attachments ? [...data.attachments] : [];
}
}
detailLoading.value = false;
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
async function handleSubmit() {
if (isView.value) {
visible.value = false;
return;
}
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const payload: Api.PersonalItem.SavePersonalItemParams = {
taskTitle: model.taskTitle.trim(),
ownerId: currentUserId.value,
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null),
attachments: [...model.attachments]
};
submitting.value = true;
const result =
isEdit.value && props.rowData
? await fetchUpdatePersonalItem({ id: props.rowData.id, ...payload })
: await fetchCreatePersonalItem(payload);
submitting.value = false;
if (result.error) {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success(isEdit.value ? '个人事项修改成功' : '个人事项创建成功');
visible.value = false;
emit('submitted');
}
watch(
() => visible.value,
value => {
if (value) {
initModel();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
width="1100px"
:loading="detailLoading"
:confirm-loading="submitting"
:show-footer="!isView"
max-body-height="78vh"
@confirm="handleSubmit"
>
<ElForm
ref="formRef"
:model="model"
:rules="rules"
label-position="top"
:validate-on-rule-change="false"
class="personal-item-operate-dialog__form"
>
<div class="personal-item-operate-dialog__grid">
<div ref="leftColRef" class="personal-item-operate-dialog__col-left">
<BusinessFormSection title="事项信息">
<ElFormItem label="事项标题" prop="taskTitle">
<ElInput
v-model="model.taskTitle"
:clearable="!isView"
:disabled="isView"
maxlength="300"
placeholder="请输入事项标题"
/>
</ElFormItem>
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
:disabled="isView"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择计划开始日期"
class="personal-item-operate-dialog__date-picker"
/>
</ElFormItem>
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
:disabled="isView"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择计划结束日期"
class="personal-item-operate-dialog__date-picker"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="personal-item-operate-dialog__col-right">
<BusinessFormSection title="事项说明">
<ElFormItem class="personal-item-operate-dialog__desc-item" prop="taskDesc">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.taskDesc"
:height="editorHeight"
:disabled="isView"
upload-directory="personal-item"
placeholder="请输入事项说明"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="personal-item-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="personal-item"
:disabled="isView"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.personal-item-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.personal-item-operate-dialog__col-left,
.personal-item-operate-dialog__col-right {
min-width: 0;
}
.personal-item-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.personal-item-operate-dialog__desc-item,
.personal-item-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.personal-item-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
:deep(.personal-item-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { fetchGetObjectStatusModelPage } from '@/service/api';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
import { personalItemStatusOptions } from './personal-item-shared';
defineOptions({ name: 'PersonalItemSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.PersonalItem.PersonalItemSearchParams>('model', {
required: true
});
const searchModel = reactive<{
keyword: string;
statusCode?: Api.PersonalItem.PersonalItemStatusCode;
}>({
keyword: '',
statusCode: undefined
});
let syncingFromSource = false;
const statusOptions = ref<Array<{ label: string; value: string }>>([...personalItemStatusOptions]);
watch(
() => [model.value.keyword, model.value.statusCode] as const,
([keyword, statusCode]) => {
syncingFromSource = true;
searchModel.keyword = keyword ?? '';
searchModel.statusCode = statusCode;
syncingFromSource = false;
},
{ immediate: true, flush: 'sync' }
);
watch(
() => [searchModel.keyword, searchModel.statusCode] as const,
([keyword, statusCode]) => {
if (syncingFromSource) {
return;
}
model.value.keyword = keyword.trim() || undefined;
model.value.statusCode = statusCode;
},
{ flush: 'sync' }
);
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '关键字',
type: 'input',
placeholder: '请输入标题或说明'
},
{
key: 'statusCode',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: statusOptions.value
}
]);
async function loadStatusOptions() {
const { error, data } = await fetchGetObjectStatusModelPage({
pageNo: 1,
pageSize: 100,
objectType: 'task',
status: 0,
initialFlag: undefined,
terminalFlag: undefined,
keyword: undefined
});
if (error || !data?.list?.length) {
statusOptions.value = [...personalItemStatusOptions];
return;
}
statusOptions.value = data.list
.slice()
.sort((left, right) => left.sort - right.sort)
.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
function handleReset() {
emit('reset');
}
function handleSearch() {
emit('search');
}
onMounted(() => {
loadStatusOptions();
});
</script>
<template>
<TableSearchFields v-model="searchModel" :fields="fields" :columns="3" @reset="handleReset" @search="handleSearch" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,105 @@
import dayjs from 'dayjs';
import { getPersonalItemStatusTagType } from '@/constants/status-tag';
export const personalItemStatusOptions = [
{ label: '待处理', value: 'pending' as const },
{ label: '进行中', value: 'active' as const },
{ label: '已完成', value: 'completed' as const }
];
const personalItemStatusLabelMap: Record<Api.PersonalItem.PersonalItemStatusCode, string> = {
pending: '待开始',
active: '进行中',
paused: '已暂停',
completed: '已完成',
cancelled: '已取消'
};
export function getPersonalItemStatusLabel(statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined) {
if (!statusCode) {
return '--';
}
return personalItemStatusLabelMap[statusCode] || '--';
}
export function resolvePersonalItemStatusTagType(
statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined
) {
return getPersonalItemStatusTagType(statusCode);
}
export function formatPersonalItemDate(value: string | null | undefined) {
if (!value) {
return '--';
}
const target = dayjs(value);
if (!target.isValid()) {
return '--';
}
return target.format('YYYY-MM-DD');
}
export function formatPersonalItemDateTime(value: string | null | undefined) {
if (!value) {
return '--';
}
const target = dayjs(value);
if (!target.isValid()) {
return '--';
}
return target.format('YYYY-MM-DD HH:mm:ss');
}
export function formatPersonalItemProgress(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '0%';
}
const normalized = Math.round(Math.min(100, Math.max(0, value)) * 100) / 100;
return `${normalized}%`;
}
export function formatPersonalItemName(value: string | null | undefined) {
return value?.trim() || '--';
}
export function formatPersonalItemOwnerName(
item: Pick<Api.PersonalItem.PersonalItem, 'ownerNickname' | 'ownerName' | 'ownerId'>
) {
return item.ownerNickname?.trim() || item.ownerName?.trim() || item.ownerId || '--';
}
export function formatPersonalItemDateRange(start: string | null | undefined, end: string | null | undefined) {
const startText = formatPersonalItemDate(start);
const endText = formatPersonalItemDate(end);
if (startText === '--' && endText === '--') {
return '--';
}
return `${startText} ~ ${endText}`;
}
export function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'PersonalItemStatusActionDialog' });
interface Props {
action: Api.PersonalItem.PersonalItemLifecycleAction | null;
}
interface Emits {
(e: 'submit', reason: string | null): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive({
reason: ''
});
const rules = computed(
() =>
({
reason: props.action?.needReason ? [createRequiredRule('请输入动作原因')] : []
}) satisfies Record<string, App.Global.FormRule[]>
);
async function handleConfirm() {
await validate();
emit('submit', model.reason.trim() || null);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.reason = '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="action?.actionName || '状态变更'" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElFormItem label="动作原因" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
:placeholder="action?.needReason ? '请输入动作原因' : '可选填写动作原因'"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,409 @@
<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

@@ -0,0 +1,656 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { 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 {
fetchCreatePersonalItemWorklog,
fetchDeletePersonalItemWorklog,
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 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 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 loadRecords();
emit('changed');
}
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 loadRecords();
emit('changed');
} 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

@@ -1,14 +1,13 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { userGenderRecord } from '@/constants/business';
import { fetchGetMyProfileDetail, fetchUpdateMyProfile } from '@/service/api';
import { buildFileProxyUrl, uploadFile } from '@/service/api/file';
import { fetchGetMyProfileDetail, fetchUpdateMyAvatar } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import ProfileInfoDialog from './modules/profile-info-dialog.vue';
import ProfilePasswordDialog from './modules/profile-password-dialog.vue';
import { buildProfileUpdatePayload, formatProfileDateTime, resolveProfileRoleLabels } from './modules/profile-model';
import { formatProfileDateTime, resolveProfileRoleLabels } from './modules/profile-model';
defineOptions({ name: 'MyProfile' });
@@ -22,6 +21,8 @@ const profileInfoVisible = ref(false);
const passwordVisible = ref(false);
const avatarInputRef = ref<HTMLInputElement | null>(null);
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
const descriptionColumns = computed(() => (appStore.isMobile ? 1 : 2));
const displayName = computed(() => profile.value?.nickname?.trim() || profile.value?.username || '--');
const displayUsername = computed(() => profile.value?.username?.trim() || '--');
@@ -41,6 +42,7 @@ const genderText = computed(() => {
return $t(userGenderRecord[value]);
});
const roleLabels = computed(() => {
const roles = profile.value?.roles ?? [];
@@ -57,16 +59,6 @@ function getAvatarText() {
return name === '--' ? 'CN' : name.slice(0, 1).toUpperCase();
}
function getEditableProfileValues(current: Api.Auth.MyProfileDetail): Api.Auth.UpdateMyProfileParams {
return {
nickname: current.nickname ?? '',
email: current.email ?? '',
mobile: current.mobile ?? '',
sex: current.sex ?? 1,
avatar: current.avatar ?? ''
};
}
async function loadProfile() {
const userId = authStore.userInfo.userId;
@@ -75,9 +67,7 @@ async function loadProfile() {
return;
}
const { data, error } = await fetchGetMyProfileDetail({
userId
});
const { data, error } = await fetchGetMyProfileDetail({ userId });
if (!error) {
profile.value = data;
@@ -115,21 +105,14 @@ async function handleAvatarChange(event: Event) {
return;
}
avatarSubmitting.value = true;
const uploadResult = await uploadFile(file, 'avatar');
if (uploadResult.error) {
avatarSubmitting.value = false;
if (file.size > MAX_AVATAR_SIZE) {
window.$message?.error('头像图片大小不能超过 5MB');
return;
}
const avatar = buildFileProxyUrl(uploadResult.data.configId, uploadResult.data.path);
const payload = buildProfileUpdatePayload({
...getEditableProfileValues(profile.value),
avatar
});
const updateResult = await fetchUpdateMyProfile(payload);
avatarSubmitting.value = true;
const updateResult = await fetchUpdateMyAvatar(file);
avatarSubmitting.value = false;
@@ -212,7 +195,7 @@ onActivated(() => {
<ElDescriptions :column="descriptionColumns" border>
<ElDescriptionsItem label="用户名">{{ displayUsername }}</ElDescriptionsItem>
<ElDescriptionsItem label="称">{{ displayName }}</ElDescriptionsItem>
<ElDescriptionsItem label="称">{{ displayName }}</ElDescriptionsItem>
<ElDescriptionsItem label="手机号">{{ mobileText }}</ElDescriptionsItem>
<ElDescriptionsItem label="邮箱">{{ emailText }}</ElDescriptionsItem>
<ElDescriptionsItem label="性别">{{ genderText }}</ElDescriptionsItem>