feat(projects): 新增项目、执行、任务等功能

This commit is contained in:
2026-05-09 11:30:34 +08:00
parent f4f43814b3
commit 824392b564
106 changed files with 13060 additions and 1049 deletions

View File

@@ -0,0 +1,406 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProjectOverviewSummary, fetchGetProjectPage, fetchGetUserSimpleList } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { getProjectStatusLabel, getProjectStatusTagType, isProjectEditable } from '../shared/project-master-data';
import ProjectOperateDialog from './modules/project-operate-dialog.vue';
import ProjectOverviewCard from './modules/project-overview-card.vue';
import ProjectSearch from './modules/project-search.vue';
defineOptions({ name: 'ProjectList' });
type ProjectPageResponse = Awaited<ReturnType<typeof fetchGetProjectPage>>;
const PROJECT_ENTRY_ROUTE_PATH = '/project/list';
function getInitSearchParams(): Api.Project.ProjectSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: '',
directionCode: undefined,
projectType: undefined,
productId: undefined,
managerUserId: undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformProjectPage(response: ProjectPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
}
function formatDateTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
const searchParams = reactive(getInitSearchParams());
const selectedStatus = ref<Api.Project.ProjectStatusCode>('active');
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
const operateVisible = ref(false);
const editingRow = ref<Api.Project.Project | null>(null);
const { routerPush } = useRouterPush();
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
const statusCounts = ref<Record<string, number>>({
pending: 0,
active: 0,
paused: 0,
completed: 0,
cancelled: 0,
archived: 0
});
const statusOptions = computed(() => [
{ value: 'pending', label: '待开始' },
{ value: 'active', label: '进行中' },
{ value: 'paused', label: '已暂停' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '作废项目' },
{ value: 'archived', label: '归档项目' }
]);
const managerLabelMap = computed(() => {
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
});
function getDirectionLabel(directionCode?: string | null) {
return getDirectionDictLabel(directionCode, '--');
}
function getProjectTypeLabelByCode(projectType?: string | null) {
return getProjectTypeLabel(projectType, '--');
}
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function createRequestParams(): Api.Project.ProjectSearchParams {
return {
...searchParams,
keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value
};
}
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
ProjectPageResponse,
Api.Project.Project
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetProjectPage(createRequestParams()),
transform: response => transformProjectPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'projectName',
label: '项目名称',
minWidth: 220,
formatter: row => (
<ElButton link type="primary" class="project-name-link" onClick={() => enterProjectContext(row)}>
{row.projectName}
</ElButton>
)
},
{ prop: 'projectCode', label: '项目编码', minWidth: 140, showOverflowTooltip: true },
{
prop: 'directionCode',
label: '项目方向',
minWidth: 140,
showOverflowTooltip: true,
formatter: row => getDirectionLabel(row.directionCode)
},
{
prop: 'projectType',
label: '项目类型',
minWidth: 140,
showOverflowTooltip: true,
formatter: row => getProjectTypeLabelByCode(row.projectType)
},
{
prop: 'managerUserId',
label: '项目经理',
minWidth: 120,
formatter: row => getManagerLabel(row.managerUserId)
},
{
prop: 'progressRate',
label: '进度',
width: 100,
align: 'center',
formatter: () => '--'
},
{
prop: 'statusCode',
label: '状态',
width: 100,
align: 'center',
formatter: row => (
<ElTag type={getProjectStatusTagType(row.statusCode)}>{getProjectStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'updateTime',
label: '最近更新',
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 108,
align: 'center',
fixed: 'right',
formatter: row => (
<BusinessTableActionCell
actions={[
{
key: 'edit',
label: '编辑',
buttonType: 'primary',
disabled: !isProjectEditable(row.statusCode),
onClick: () => openEdit(row)
}
]}
/>
)
}
],
immediate: false
});
async function loadManagerOptions() {
const { error, data: userList } = await fetchGetUserSimpleList();
if (error || !userList) {
managerUserOptions.value = [];
managerFilterOptions.value = [];
return;
}
const userSimpleList = sortManagerOptions(userList);
managerUserOptions.value = userSimpleList;
managerFilterOptions.value = userSimpleList;
}
async function loadOverviewData() {
const { error, data: overviewSummary } = await fetchGetProjectOverviewSummary();
if (error || !overviewSummary) {
statusCounts.value = {};
return;
}
statusCounts.value = overviewSummary.statusCounts || {};
}
async function reloadProjectTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
async function refreshPageData(page = searchParams.pageNo ?? 1) {
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProjectTable(page)]);
}
async function handleSearch() {
await reloadProjectTable(1);
}
async function handleResetSearch() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, getInitSearchParams(), {
pageSize
});
await reloadProjectTable(1);
}
async function handleStatusChange(status: Api.Project.ProjectStatusCode) {
selectedStatus.value = status;
await reloadProjectTable(1);
}
function openCreate() {
editingRow.value = null;
operateVisible.value = true;
}
function openEdit(row: Api.Project.Project) {
editingRow.value = row;
operateVisible.value = true;
}
async function enterProjectContext(row: Api.Project.Project) {
await routerPush({
path: PROJECT_ENTRY_ROUTE_PATH,
query: {
[OBJECT_CONTEXT_QUERY_KEY]: row.id
}
});
}
async function handleProjectSubmitted(projectId?: string) {
const isEditing = Boolean(projectId && editingRow.value?.id === projectId);
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
if (isEditing) {
editingRow.value = null;
}
}
onMounted(async () => {
await refreshPageData();
});
</script>
<template>
<div
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[396px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ProjectOverviewCard
:status-counts="statusCounts"
:direction-count="directionOptions.length"
:selected-status="selectedStatus"
@status-change="handleStatusChange"
/>
</div>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ProjectSearch
v-model:model="searchParams"
:manager-options="managerFilterOptions"
@reset="handleResetSearch"
@search="handleSearch"
/>
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="business-table-card-body">
<template #header>
<div class="project-card-header">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-8px">
<p class="truncate text-16px font-600">项目列表</p>
<ElTag effect="plain" :type="getProjectStatusTagType(selectedStatus)">
{{
statusOptions.find(item => item.value === selectedStatus)?.label ||
getProjectStatusLabel(selectedStatus)
}}
</ElTag>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="refreshPageData"
>
<template #default>
<ElButton plain type="primary" @click="openCreate">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新建
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
<template #empty>
<ElEmpty description="当前筛选条件下暂无项目" />
</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>
</div>
<ProjectOperateDialog
v-model:visible="operateVisible"
:manager-user-options="managerUserOptions"
:row-data="editingRow"
@submitted="handleProjectSubmitted"
/>
</div>
</template>
<style lang="scss" scoped>
.project-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.project-name-link {
padding: 0;
}
@media (width <= 1280px) {
.project-card-header {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,511 @@
<script setup lang="tsx">
import { computed, nextTick, ref, watch } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreateProject, fetchGetProductPage, fetchUpdateProject } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProjectOperateDialog' });
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
rowData?: Api.Project.Project | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', projectId?: string): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
interface Model {
projectCode: string;
projectName: string;
directionCode: string;
projectType: string;
productId: string | null;
managerUserId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
projectDesc: string;
}
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const isEditMode = computed(() => Boolean(props.rowData?.id));
const dialogTitle = computed(() => (isEditMode.value ? '编辑项目' : '新增项目'));
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
// 产品选项,包含 ID、名称、方向
interface ProductOption {
id: string;
name: string;
directionCode: string;
}
const productOptions = ref<ProductOption[]>([]);
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
return '';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
// 当前选中产品的方向
const selectedProductDirection = computed(() => {
if (!model.value.productId) {
return '';
}
const product = productOptions.value.find(p => p.id === model.value.productId);
return product?.directionCode || '';
});
// 判断是否关联了产品(创建/编辑模式都适用)
const hasAssociatedProduct = computed(() => Boolean(model.value.productId));
// 方向字段是否只读:关联了产品时只读,未关联时可编辑
const directionReadonly = computed(() => hasAssociatedProduct.value);
// 当前生效的方向:关联产品则用产品方向,否则用用户选择的方向
const effectiveDirectionCode = computed({
get: () => {
if (hasAssociatedProduct.value) {
// 编辑/创建模式下,关联产品时使用产品方向
return selectedProductDirection.value || model.value.directionCode;
}
return model.value.directionCode;
},
set: (val: string) => {
if (!hasAssociatedProduct.value) {
model.value.directionCode = val;
}
}
});
const directionDisplayName = computed(() => {
const directionCode = effectiveDirectionCode.value;
if (!directionCode) {
return '';
}
return getDirectionLabel(directionCode, directionCode);
});
function parsePlannedDate(value: string | null | undefined) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
let startDate = parsePlannedDate(model.value.plannedStartDate);
if (!startDate) {
startDate = new Date();
model.value.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
}
const endDate = new Date(startDate.getTime());
mutator(endDate);
return endDate;
}
};
}
const plannedEndDateShortcuts = [
buildEndDateShortcut('一星期', date => date.setDate(date.getDate() + 7)),
buildEndDateShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildEndDateShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
buildEndDateShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
buildEndDateShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
buildEndDateShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
// 产品下拉的标签,显示产品名称 + 方向
const productOptionLabel = (item: ProductOption) => {
return `${item.name}`;
};
const rules = {
projectName: [createRequiredRule('请输入项目名称')],
directionCode: [createRequiredRule('请选择项目方向')],
projectType: [createRequiredRule('请选择项目类型')],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(model.value.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
callback();
},
trigger: 'change'
}
]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
projectCode: '',
projectName: '',
directionCode: '',
projectType: '',
productId: null,
managerUserId: null,
plannedStartDate: null,
plannedEndDate: null,
projectDesc: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function loadProductOptions() {
const { error, data } = await fetchGetProductPage({ pageNo: 1, pageSize: 200 });
if (error || !data) {
productOptions.value = [];
return;
}
productOptions.value = data.list.map(item => ({
id: item.id,
name: item.name || item.code || item.id,
directionCode: item.directionCode || ''
}));
}
// 监听产品选择变化,联动方向(创建模式)
watch(
() => model.value.productId,
(newProductId, oldProductId) => {
if (isEditMode.value) {
return; // 编辑模式下不处理,产品字段只读
}
if (newProductId && newProductId !== oldProductId) {
// 选择了产品,自动填充方向
const product = productOptions.value.find(p => p.id === newProductId);
if (product) {
model.value.directionCode = product.directionCode;
}
}
// 取消选择产品时directionCode 保留,用户可重新选择
}
);
async function handleSubmit() {
await validate();
// 提交时,如果关联了产品,使用产品方向
const finalDirectionCode = hasAssociatedProduct.value
? selectedProductDirection.value || model.value.directionCode
: model.value.directionCode;
const payload: Api.Project.SaveProjectParams = {
projectCode: getNullableText(model.value.projectCode),
projectName: model.value.projectName.trim(),
directionCode: finalDirectionCode,
projectType: model.value.projectType,
productId: model.value.productId,
managerUserId: model.value.managerUserId || '',
plannedStartDate: model.value.plannedStartDate,
plannedEndDate: model.value.plannedEndDate,
actualStartDate: isEditMode.value ? props.rowData?.actualStartDate || null : undefined,
actualEndDate: isEditMode.value ? props.rowData?.actualEndDate || null : undefined,
projectDesc: getNullableText(model.value.projectDesc)
};
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const updateParams: Api.Project.UpdateProjectParams = {
id: props.rowData.id,
...payload
};
const result = await fetchUpdateProject(updateParams);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('项目编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
return;
}
const result = await fetchCreateProject(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('项目新增成功');
closeDialog();
emit('submitted', result.data);
}
watch(visible, async value => {
if (!value) {
return;
}
await loadProductOptions();
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
await nextTick();
formRef.value?.clearValidate();
return;
}
model.value = {
projectCode: props.rowData.projectCode || '',
projectName: props.rowData.projectName || '',
directionCode: props.rowData.directionCode || '',
projectType: props.rowData.projectType || '',
productId: props.rowData.productId,
managerUserId: props.rowData.managerUserId || null,
plannedStartDate: props.rowData.plannedStartDate,
plannedEndDate: props.rowData.plannedEndDate,
projectDesc: props.rowData.projectDesc || ''
};
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="md"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<BusinessFormSection title="项目信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="项目编码" prop="projectCode">
<ElInput
:model-value="model.projectCode"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目编码"
/>
</ElFormItem>
<ElFormItem v-else label="项目编码" prop="projectCode">
<ElInput v-model="model.projectCode" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目名称" prop="projectName">
<ElInput v-model="model.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目方向" prop="directionCode">
<DictSelect
v-if="!directionReadonly"
v-model="effectiveDirectionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择项目方向"
/>
<ElInput
v-else
:model-value="directionDisplayName"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目类型" prop="projectType">
<DictSelect
v-model="model.projectType"
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
filterable
placeholder="请选择项目类型"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="所属产品">
<ElInput
:model-value="
productOptions.find(p => p.id === model.productId)?.name ||
props.rowData?.productName ||
model.productId ||
'未关联产品'
"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未关联产品"
/>
</ElFormItem>
<ElFormItem v-else label="所属产品" prop="productId">
<ElSelect
v-model="model.productId"
clearable
filterable
placeholder="选择所属产品可选选择后将锁定项目方向"
>
<ElOption
v-for="item in productOptions"
:key="item.id"
:label="productOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode">
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整项目经理请到项目内的团队管理处处理"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>项目经理</span>
</span>
</template>
<ElInput
:model-value="managerDisplayName"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目经理"
/>
</ElFormItem>
<ElFormItem v-else label="项目经理" prop="managerUserId">
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择项目经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择开始日期"
class="project-operate-dialog__date-picker"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择结束日期"
:shortcuts="plannedEndDateShortcuts"
class="project-operate-dialog__date-picker"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="项目说明" prop="projectDesc">
<ElInput
v-model="model.projectDesc"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入项目说明"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.project-operate-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.project-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.project-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.project-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
:deep(.project-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { CircleCheckFilled, DeleteFilled, DocumentAdd, FolderOpened, VideoPause } from '@element-plus/icons-vue';
defineOptions({ name: 'ProjectOverviewCard' });
interface StatusNavMeta {
key: Api.Project.ProjectStatusCode;
label: string;
description: string;
tone: 'teal' | 'slate' | 'amber' | 'rose' | 'indigo';
icon: Component;
}
interface Props {
statusCounts: Record<string, number>;
directionCount: number;
selectedStatus: Api.Project.ProjectStatusCode;
}
const props = defineProps<Props>();
interface Emits {
(e: 'status-change', status: Api.Project.ProjectStatusCode): void;
}
const emit = defineEmits<Emits>();
const statusNavMetas: StatusNavMeta[] = [
{
key: 'pending',
label: '待开始',
description: '项目已创建,等待启动',
tone: 'indigo',
icon: DocumentAdd
},
{
key: 'active',
label: '进行中',
description: '正在执行的项目',
tone: 'teal',
icon: CircleCheckFilled
},
{
key: 'paused',
label: '已暂停',
description: '暂时停止推进的项目',
tone: 'amber',
icon: VideoPause
},
{
key: 'completed',
label: '已完成',
description: '达成目标的项目',
tone: 'teal',
icon: FolderOpened
},
{
key: 'cancelled',
label: '作废项目',
description: '已终止或取消推进的项目',
tone: 'rose',
icon: DeleteFilled
},
{
key: 'archived',
label: '归档项目',
description: '已收口归档的历史项目',
tone: 'slate',
icon: FolderOpened
}
];
const statusItems = computed(() =>
statusNavMetas.map(item => ({
...item,
count: props.statusCounts[item.key] ?? 0
}))
);
const overviewMetrics = computed(() => [
{
label: '总项目数',
value: Object.values(props.statusCounts).reduce((sum, count) => sum + count, 0),
hint: '当前接口可查询到的项目总量'
},
{
label: '进行中',
value: props.statusCounts.active ?? 0,
hint: '正在执行的项目'
},
{
label: '待开始',
value: props.statusCounts.pending ?? 0,
hint: '等待启动的项目'
},
{
label: '作废项目',
value: props.statusCounts.cancelled ?? 0,
hint: '已终止或取消推进的项目'
}
]);
function handleStatusClick(status: Api.Project.ProjectStatusCode) {
emit('status-change', status);
}
</script>
<template>
<ElCard class="project-overview-card card-wrapper">
<div class="project-overview-card__stats">
<div v-for="item in overviewMetrics" :key="item.label" class="project-overview-card__stat">
<span class="project-overview-card__stat-label">{{ item.label }}</span>
<strong class="project-overview-card__stat-value">{{ item.value }}</strong>
<small class="project-overview-card__stat-hint">{{ item.hint }}</small>
</div>
</div>
<div class="project-status-panel__list">
<button
v-for="item in statusItems"
:key="item.key"
type="button"
class="project-status-item"
:class="[`project-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
:aria-pressed="selectedStatus === item.key"
@click="handleStatusClick(item.key)"
>
<div class="project-status-item__icon">
<ElIcon>
<component :is="item.icon" />
</ElIcon>
</div>
<div class="project-status-item__main">
<div class="project-status-item__top">
<strong>{{ item.label }}</strong>
<em>{{ item.count }}</em>
</div>
<p class="project-status-item__desc">{{ item.description }}</p>
</div>
</button>
</div>
</ElCard>
</template>
<style lang="scss" scoped>
.project-overview-card {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(14 165 233 / 8%), transparent 36%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.project-overview-card__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-overview-card__stat {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 84%);
}
.project-overview-card__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 13px;
}
.project-overview-card__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.project-overview-card__stat-hint {
color: rgb(100 116 139 / 90%);
font-size: 12px;
line-height: 1.5;
}
.project-status-panel__list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.project-status-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 18px;
background-color: rgb(255 255 255 / 86%);
text-align: left;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.project-status-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 60%);
}
.project-status-item.is-active {
border-color: rgb(14 165 233 / 40%);
box-shadow: 0 10px 24px rgb(14 165 233 / 8%);
}
.project-status-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 14px;
font-size: 20px;
}
.project-status-item__main {
min-width: 0;
flex: 1;
}
.project-status-item__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.project-status-item__top strong {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.project-status-item__top em {
color: rgb(15 23 42 / 88%);
font-size: 18px;
font-style: normal;
font-weight: 700;
}
.project-status-item__desc {
color: rgb(100 116 139 / 94%);
font-size: 13px;
line-height: 1.6;
}
.project-status-item--teal .project-status-item__icon {
background-color: rgb(240 253 250 / 96%);
color: rgb(15 118 110 / 96%);
}
.project-status-item--slate .project-status-item__icon {
background-color: rgb(241 245 249 / 96%);
color: rgb(51 65 85 / 92%);
}
.project-status-item--amber .project-status-item__icon {
background-color: rgb(255 251 235 / 96%);
color: rgb(217 119 6 / 92%);
}
.project-status-item--rose .project-status-item__icon {
background-color: rgb(255 241 242 / 96%);
color: rgb(225 29 72 / 92%);
}
.project-status-item--indigo .project-status-item__icon {
background-color: rgb(238 242 255 / 96%);
color: rgb(79 70 229 / 92%);
}
@media (width <= 1280px) {
.project-status-item__top {
align-items: flex-start;
flex-direction: column;
}
}
@media (width <= 640px) {
.project-overview-card__stats {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ProjectSearch' });
interface Props {
managerOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.Project.ProjectSearchParams>('model', { required: true });
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '关键词',
type: 'input',
placeholder: '项目编码 / 名称'
},
{
key: 'directionCode',
label: '项目方向',
type: 'dict',
dictCode: RDMS_OBJECT_DIRECTION_DICT_CODE,
placeholder: '筛选项目方向'
},
{
key: 'projectType',
label: '项目类型',
type: 'dict',
dictCode: RDMS_PROJECT_TYPE_DICT_CODE,
placeholder: '筛选项目类型'
},
{
key: 'managerUserId',
label: '项目经理',
type: 'select',
options: props.managerOptions.map(item => ({
label: item.nickname,
value: item.id
})),
placeholder: '筛选项目经理'
}
]);
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,475 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import {
fetchChangeProjectExecutionOwner,
fetchChangeProjectExecutionStatus,
fetchCreateProjectExecution,
fetchCreateProjectExecutionMember,
fetchGetProjectExecution,
fetchGetProjectExecutionMembers,
fetchGetProjectExecutionPage,
fetchGetProjectExecutionStatusBoard,
fetchGetProjectMembers,
fetchInactiveProjectExecutionMember,
fetchUpdateProjectExecution
} from '@/service/api';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { useCurrentProject } from '../../shared/use-current-project';
import ExecutionListPanel from './modules/execution-list-panel.vue';
import ExecutionMemberDialog from './modules/execution-member-dialog.vue';
import ExecutionOperateDialog from './modules/execution-operate-dialog.vue';
import StatusActionDialog from './modules/status-action-dialog.vue';
import TaskWorkspace from './modules/task-workspace.vue';
defineOptions({ name: 'ProjectExecution' });
type ExecutionPageResponse = Awaited<ReturnType<typeof fetchGetProjectExecutionPage>>;
type ExecutionAction = Api.Project.LifecycleAction<Api.Project.ProjectExecutionActionCode>;
type ExecutionStatusFilter = string | null;
function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: '',
executionType: undefined,
ownerId: undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformExecutionPage(response: ExecutionPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const { currentObjectId } = useCurrentProject();
const objectContextStore = useObjectContextStore();
const searchParams = reactive(getInitExecutionSearchParams());
const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = 'active';
const selectedStatus = ref<ExecutionStatusFilter>(DEFAULT_EXECUTION_STATUS);
const selectedExecution = ref<Api.Project.ProjectExecution | null>(null);
const projectMembers = ref<Api.Project.ProjectMember[]>([]);
const projectMemberOptions = ref<Api.SystemManage.UserSimple[]>([]);
const operateVisible = ref(false);
const operateMode = ref<'create' | 'edit' | 'view'>('create');
const memberVisible = ref(false);
const statusVisible = ref(false);
const editingExecution = ref<Api.Project.ProjectExecution | null>(null);
const editingExecutionMembers = ref<Api.Project.ExecutionMember[]>([]);
const statusExecution = ref<Api.Project.ProjectExecution | null>(null);
const statusAction = ref<ExecutionAction | null>(null);
const executionMembers = ref<Api.Project.ExecutionMember[]>([]);
const memberLoading = ref(false);
const executionStatusBoard = ref<Api.Project.StatusBoard | null>(null);
const projectId = computed(() => currentObjectId.value || '');
const statusActionTitle = computed(() =>
statusAction.value ? `执行状态变更:${statusAction.value.actionName}` : '执行状态变更'
);
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
const canUpdateExecution = computed(() => buttonCodeSet.value.has('project:execution:update'));
const canChangeExecutionOwner = computed(() => buttonCodeSet.value.has('project:execution:owner'));
const canManageExecutionMember = computed(() => buttonCodeSet.value.has('project:execution:member'));
const canChangeExecutionStatus = computed(() => buttonCodeSet.value.has('project:execution:status'));
const canDeleteExecution = computed(() => buttonCodeSet.value.has('project:execution:delete'));
const canCreateTask = computed(() => buttonCodeSet.value.has('project:task:create'));
const canUpdateTask = computed(() => buttonCodeSet.value.has('project:task:update'));
const canChangeTaskStatus = computed(() => buttonCodeSet.value.has('project:task:status'));
function createRequestParams(): Api.Project.ProjectExecutionSearchParams {
return {
...searchParams,
keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value || undefined
};
}
function createStatusBoardParams(): Api.Project.ProjectExecutionStatusBoardParams {
return {
keyword: searchParams.keyword?.trim() || undefined,
executionType: searchParams.executionType,
ownerId: searchParams.ownerId,
updateTime: searchParams.updateTime
};
}
const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
ExecutionPageResponse,
Api.Project.ProjectExecution
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => {
if (!projectId.value) {
return Promise.resolve({
data: { total: 0, list: [] },
error: null
} as unknown as ExecutionPageResponse);
}
return fetchGetProjectExecutionPage(projectId.value, createRequestParams());
},
transform: response => transformExecutionPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [{ prop: 'executionName', label: '执行名称', minWidth: 160 }],
immediate: false
});
function syncSelectedExecution() {
if (!data.value.length) {
selectedExecution.value = null;
return;
}
selectedExecution.value = data.value.find(item => item.id === selectedExecution.value?.id) || data.value[0];
}
async function loadProjectMemberOptions() {
if (!projectId.value) {
projectMemberOptions.value = [];
return;
}
const { error, data: members } = await fetchGetProjectMembers(projectId.value);
if (error || !members) {
projectMembers.value = [];
projectMemberOptions.value = [];
return;
}
projectMembers.value = members.filter(item => item.status === 0);
projectMemberOptions.value = projectMembers.value.map(item => ({
id: item.userId,
nickname: item.userNickname || item.userId,
username: null,
deptName: item.roleName || item.roleCode || null
}));
}
async function reloadExecutionData(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
syncSelectedExecution();
}
async function loadExecutionStatusBoard() {
if (!projectId.value) {
executionStatusBoard.value = null;
return;
}
const { error, data: board } = await fetchGetProjectExecutionStatusBoard(projectId.value, createStatusBoardParams());
executionStatusBoard.value = error || !board ? null : board;
}
async function refreshPageData() {
await Promise.all([loadProjectMemberOptions(), reloadExecutionData(), loadExecutionStatusBoard()]);
}
async function handleSearch() {
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function handleReset() {
Object.assign(searchParams, getInitExecutionSearchParams());
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function handleStatusChange(status: ExecutionStatusFilter) {
selectedStatus.value = status;
await reloadExecutionData(1);
}
async function getExecutionDetail(row: Api.Project.ProjectExecution) {
if (!projectId.value) {
return row;
}
const result = await fetchGetProjectExecution(projectId.value, row.id);
return result.error || !result.data ? row : result.data;
}
function openCreateExecution() {
editingExecution.value = null;
editingExecutionMembers.value = [];
operateMode.value = 'create';
operateVisible.value = true;
}
async function openEditExecution(row: Api.Project.ProjectExecution) {
const detail = await getExecutionDetail(row);
if (!detail.allowEdit) {
window.$message?.warning('当前执行状态不允许编辑');
return;
}
editingExecution.value = detail;
const memberResult = await fetchGetProjectExecutionMembers(projectId.value, detail.id);
editingExecutionMembers.value = memberResult.error || !memberResult.data ? [] : memberResult.data;
operateMode.value = 'edit';
operateVisible.value = true;
}
async function openViewExecution(row: Api.Project.ProjectExecution) {
const detail = await getExecutionDetail(row);
editingExecution.value = detail;
const memberResult = await fetchGetProjectExecutionMembers(projectId.value, detail.id);
editingExecutionMembers.value = memberResult.error || !memberResult.data ? [] : memberResult.data;
operateMode.value = 'view';
operateVisible.value = true;
}
async function openMemberDialog(row: Api.Project.ProjectExecution) {
selectedExecution.value = await getExecutionDetail(row);
memberVisible.value = true;
await loadExecutionMembers(selectedExecution.value.id);
}
async function openExecutionStatus(row: Api.Project.ProjectExecution, action: ExecutionAction | null) {
const detail = await getExecutionDetail(row);
const targetAction = action || detail.availableActions[0] || null;
if (!targetAction) {
window.$message?.warning('当前执行暂无可用状态操作');
return;
}
statusExecution.value = detail;
statusAction.value = targetAction;
statusVisible.value = true;
}
async function loadExecutionMembers(executionId: string) {
if (!projectId.value) {
executionMembers.value = [];
return;
}
memberLoading.value = true;
try {
const { error, data: members } = await fetchGetProjectExecutionMembers(projectId.value, executionId);
executionMembers.value = error || !members ? [] : members;
} finally {
memberLoading.value = false;
}
}
async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionParams) {
if (!projectId.value) {
return;
}
const result = editingExecution.value
? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, payload)
: await fetchCreateProjectExecution(projectId.value, payload);
if (!result.error) {
operateVisible.value = false;
await Promise.all([
reloadExecutionData(editingExecution.value ? (searchParams.pageNo ?? 1) : 1),
loadExecutionStatusBoard()
]);
}
}
async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) {
if (!projectId.value || !selectedExecution.value) {
return;
}
const result = await fetchChangeProjectExecutionOwner(projectId.value, selectedExecution.value.id, payload);
if (!result.error) {
selectedExecution.value = await getExecutionDetail(selectedExecution.value);
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
}
}
async function handleExecutionStatusSubmit(reason: string | null) {
if (!projectId.value || !statusExecution.value || !statusAction.value) {
return;
}
const result = await fetchChangeProjectExecutionStatus(projectId.value, statusExecution.value.id, {
actionCode: statusAction.value.actionCode,
reason
});
if (!result.error) {
statusVisible.value = false;
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
}
}
async function handleAddExecutionMember(payload: Api.Project.CreateExecutionMemberParams) {
if (!projectId.value || !selectedExecution.value) {
return;
}
const result = await fetchCreateProjectExecutionMember(projectId.value, selectedExecution.value.id, payload);
if (!result.error) {
await loadExecutionMembers(selectedExecution.value.id);
}
}
async function handleInactiveExecutionMember(
member: Api.Project.ExecutionMember,
payload: Api.Project.InactiveExecutionMemberParams
) {
if (!projectId.value || !selectedExecution.value) {
return;
}
const result = await fetchInactiveProjectExecutionMember(projectId.value, selectedExecution.value.id, {
memberId: member.id,
data: payload
});
if (!result.error) {
await loadExecutionMembers(selectedExecution.value.id);
}
}
function handleDeleteExecution(_row: Api.Project.ProjectExecution) {
window.$message?.warning('删除接口暂未开放,请等待后端发布');
}
watch(
() => projectId.value,
async () => {
selectedExecution.value = null;
Object.assign(searchParams, getInitExecutionSearchParams());
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
await refreshPageData();
},
{ immediate: true }
);
</script>
<template>
<div v-if="projectId" class="project-execution-page">
<ExecutionListPanel
v-model:search-model="searchParams"
class="project-execution-page__aside"
:data="data"
:loading="loading"
:pagination="mobilePagination"
:selected-id="selectedExecution?.id || null"
:status-board="executionStatusBoard"
:selected-status="selectedStatus"
:owner-options="projectMemberOptions"
:can-create="canCreateExecution"
:can-update="canUpdateExecution"
:can-change-owner="canChangeExecutionOwner"
:can-manage-member="canManageExecutionMember"
:can-change-status="canChangeExecutionStatus"
:can-delete="canDeleteExecution"
@select="selectedExecution = $event"
@status-change="handleStatusChange"
@search="handleSearch"
@reset="handleReset"
@create="openCreateExecution"
@edit="openEditExecution"
@view="openViewExecution"
@members="openMemberDialog"
@status-action="openExecutionStatus"
@delete="handleDeleteExecution"
/>
<TaskWorkspace
class="project-execution-page__main"
:project-id="projectId"
:execution="selectedExecution"
:owner-options="projectMemberOptions"
:can-create="canCreateTask"
:can-update="canUpdateTask"
:can-change-status="canChangeTaskStatus"
/>
<ExecutionOperateDialog
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="editingExecution"
:user-options="projectMemberOptions"
:current-members="editingExecutionMembers"
@submit="handleExecutionSubmit"
/>
<ExecutionMemberDialog
v-model:visible="memberVisible"
:execution="selectedExecution"
:members="executionMembers"
:user-options="projectMemberOptions"
:loading="memberLoading"
:can-manage-member="canManageExecutionMember"
:can-change-owner="canChangeExecutionOwner"
@add="handleAddExecutionMember"
@inactive="handleInactiveExecutionMember"
@change-owner="handleChangeOwner"
/>
<StatusActionDialog
v-model:visible="statusVisible"
:title="statusActionTitle"
:action="statusAction"
@submit="handleExecutionStatusSubmit"
/>
</div>
<ElEmpty v-else description="未获取到当前项目上下文,请返回项目列表重新选择项目" />
</template>
<style scoped>
.project-execution-page {
display: grid;
min-height: 560px;
gap: 16px;
overflow: hidden;
grid-template-columns: 396px minmax(0, 1fr);
}
.project-execution-page__aside,
.project-execution-page__main {
min-width: 0;
min-height: 0;
}
@media (width <= 1280px) {
.project-execution-page {
display: flex;
flex-direction: column;
overflow: auto;
}
}
</style>

View File

@@ -0,0 +1,49 @@
import {
EXECUTION_STATUS_ORDER,
TASK_STATUS_ORDER,
executionStatusFallbackNameMap,
taskStatusFallbackNameMap
} from './shared';
export const mockExecutionStatusCounts: Record<Api.Project.ProjectExecutionStatusCode, number> =
EXECUTION_STATUS_ORDER.reduce(
(counts, statusCode) => ({
...counts,
[statusCode]: 0
}),
{} as Record<Api.Project.ProjectExecutionStatusCode, number>
);
export const mockTaskStatusColumns = TASK_STATUS_ORDER.map(statusCode => ({
statusCode,
statusName: taskStatusFallbackNameMap[statusCode],
tasks: [] as Api.Project.ProjectTask[]
}));
export function createEmptyExecution(projectId: string): Api.Project.ProjectExecution {
const now = new Date().toISOString();
return {
id: '',
projectId,
projectRequirementId: null,
executionName: '',
executionType: null,
ownerId: '',
ownerNickname: null,
statusCode: 'pending',
statusName: executionStatusFallbackNameMap.pending,
terminal: false,
allowEdit: true,
availableActions: [],
plannedStartDate: null,
plannedEndDate: null,
actualStartDate: null,
actualEndDate: null,
progressRate: 0,
executionDesc: null,
lastStatusReason: null,
createTime: now,
updateTime: now
};
}

View File

@@ -0,0 +1,605 @@
<script setup lang="ts">
import { computed, markRaw } from 'vue';
import type { PaginationProps } from 'element-plus';
import { Calendar, Flag, Plus, TrendCharts, User } from '@element-plus/icons-vue';
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType, getProgressText } from '../shared';
import IconMdiAccountMultipleOutline from '~icons/mdi/account-multiple-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-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: 'ProjectExecutionListPanel' });
type ExecutionStatusFilter = string | null;
interface Props {
data: Api.Project.ProjectExecution[];
loading: boolean;
pagination: Partial<PaginationProps & Record<string, any>>;
selectedId: string | null;
statusBoard: Api.Project.StatusBoard | null;
selectedStatus: ExecutionStatusFilter;
ownerOptions: Api.SystemManage.UserSimple[];
canCreate: boolean;
canUpdate: boolean;
canChangeOwner: boolean;
canManageMember: boolean;
canChangeStatus: boolean;
canDelete: boolean;
}
const props = defineProps<Props>();
interface Emits {
(e: 'select', row: Api.Project.ProjectExecution): void;
(e: 'status-change', status: ExecutionStatusFilter): void;
(e: 'create'): void;
(e: 'edit', row: Api.Project.ProjectExecution): void;
(e: 'view', row: Api.Project.ProjectExecution): void;
(e: 'members', row: Api.Project.ProjectExecution): void;
(e: 'delete', row: Api.Project.ProjectExecution): void;
(e: 'search'): void;
(e: 'reset'): void;
(
e: 'status-action',
row: Api.Project.ProjectExecution,
action: Api.Project.LifecycleAction<Api.Project.ProjectExecutionActionCode> | null
): void;
}
const emit = defineEmits<Emits>();
const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
function handleSearch() {
emit('search');
}
function handleKeywordInput(value: string) {
searchModel.value.keyword = value.trim() || undefined;
}
function handleKeywordClear() {
searchModel.value.keyword = undefined;
handleSearch();
}
function handleOwnerSelect(id: string | null | undefined) {
searchModel.value.ownerId = id || undefined;
handleSearch();
}
function handleReset() {
emit('reset');
}
const paginationVisible = computed(() => {
const total = Number(props.pagination.total || 0);
return total > 0;
});
const totalCount = computed(() => props.statusBoard?.total ?? 0);
const statusItems = computed(() => [
{
key: null,
label: '全部',
count: totalCount.value
},
...(props.statusBoard?.items ?? []).map(item => ({
key: item.statusCode,
label: item.statusName,
count: item.count
}))
]);
function handleStatusClick(status: ExecutionStatusFilter) {
emit('status-change', status);
}
function handleSelect(row: Api.Project.ProjectExecution) {
emit('select', row);
}
function handlePageChange(page: number) {
props.pagination['current-change']?.(page);
}
function handleSizeChange(pageSize: number) {
props.pagination['size-change']?.(pageSize);
}
interface ExecutionAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'danger';
onClick: () => void;
}
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
start: markRaw(IconMdiPlay),
pause: markRaw(IconMdiPause),
resume: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline)
};
function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
const actions: ExecutionAction[] = [];
const isCancelled = row.statusCode === 'cancelled';
if (isCancelled) {
actions.push({
key: 'view',
tooltip: '查看',
icon: markRaw(IconMdiEyeOutline),
type: 'primary',
onClick: () => emit('view', row)
});
return actions;
}
if (props.canUpdate && !isCancelled) {
actions.push({
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: () => emit('edit', row)
});
}
if ((props.canManageMember || props.canChangeOwner) && !isCancelled) {
actions.push({
key: 'members',
tooltip: '成员管理',
icon: markRaw(IconMdiAccountMultipleOutline),
type: 'primary',
onClick: () => emit('members', row)
});
}
if (!props.canChangeStatus) {
return actions;
}
if (!row.availableActions.length) {
if (row.statusCode === 'pending') {
actions.push({
key: 'cancel',
tooltip: '取消',
icon: markRaw(IconMdiCloseCircleOutline),
type: 'danger',
onClick: () =>
emit('status-action', row, {
actionCode: 'cancel',
actionName: '取消',
needReason: true
})
});
}
return actions;
}
row.availableActions.forEach(action => {
actions.push({
key: action.actionCode,
tooltip: action.actionName,
icon: markRaw(STATUS_ACTION_ICON_MAP[action.actionCode] ?? IconMdiSync),
type: action.actionCode === 'cancel' ? 'danger' : 'success',
onClick: () => emit('status-action', row, action)
});
});
return actions;
}
</script>
<template>
<section class="execution-list-panel">
<header class="execution-list-panel__header">
<h3 class="execution-list-panel__title">执行池</h3>
<ElButton v-if="canCreate" type="primary" :icon="Plus" @click="emit('create')">新增</ElButton>
</header>
<div class="execution-list-panel__search">
<ElInput
:model-value="searchModel.keyword ?? ''"
class="execution-search-input"
placeholder="搜索执行名称"
@update:model-value="handleKeywordInput"
@keyup.enter="handleSearch"
>
<template #suffix>
<ElIcon v-if="searchModel.keyword" class="execution-search-input__clear" @click="handleKeywordClear">
<icon-mdi-close-circle />
</ElIcon>
</template>
</ElInput>
<ElSelect
:model-value="searchModel.ownerId ?? null"
class="execution-owner-select"
placeholder="负责人"
clearable
filterable
@change="handleOwnerSelect"
>
<ElOption v-for="item in ownerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
<div class="execution-search__icons">
<ElTooltip content="重置" placement="top">
<ElButton link class="execution-search-input__btn" @click="handleReset">
<icon-mdi-refresh class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip content="搜索" placement="top">
<ElButton link class="execution-search-input__btn" type="primary" @click="handleSearch">
<icon-ic-round-search class="text-15px" />
</ElButton>
</ElTooltip>
</div>
</div>
<div class="execution-status-grid" aria-label="执行状态筛选">
<button
v-for="item in statusItems"
:key="item.key || 'all'"
type="button"
class="execution-status-grid__item"
:class="{ 'is-active': selectedStatus === item.key }"
:aria-pressed="selectedStatus === item.key"
@click="handleStatusClick(item.key)"
>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</button>
</div>
<ElScrollbar class="execution-list-panel__scrollbar">
<ElSkeleton v-if="loading" :rows="5" animated />
<ElEmpty v-else-if="data.length === 0" class="execution-list-panel__empty" description="暂无执行项" />
<div v-else class="execution-list-panel__list">
<article
v-for="row in data"
:key="row.id"
class="execution-item"
:class="{ 'is-active': selectedId === row.id }"
@click="handleSelect(row)"
>
<div class="execution-item__main">
<div class="execution-item__top">
<strong class="execution-item__name">{{ row.executionName || '未命名执行' }}</strong>
<ElTag
class="execution-item__status-tag"
:type="getExecutionStatusTagType(row.statusCode)"
effect="light"
size="small"
>
{{ getExecutionStatusName(row) }}
</ElTag>
</div>
<div class="execution-item__meta">
<span>
<ElIcon><User /></ElIcon>
{{ row.ownerNickname || '未设置负责人' }}
</span>
<span>
<ElIcon><Flag /></ElIcon>
计划 {{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}
</span>
<span>
<ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
</span>
<span class="execution-item__progress-row">
<ElIcon><TrendCharts /></ElIcon>
<ElProgress class="execution-item__progress" :percentage="row.progressRate" :stroke-width="6" />
</span>
</div>
</div>
<div class="execution-item__actions" @click.stop>
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="execution-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-14px" />
</ElButton>
</ElTooltip>
<ElPopconfirm
v-if="canDelete && (row.statusCode === 'pending' || row.statusCode === 'cancelled')"
title="确认删除该执行?删除后不可恢复"
confirm-button-text="删除"
cancel-button-text="取消"
confirm-button-type="danger"
@confirm="emit('delete', row)"
>
<template #reference>
<span class="inline-flex">
<ElTooltip content="删除">
<ElButton link type="danger" class="execution-action-btn">
<icon-mdi-delete-outline class="text-14px" />
</ElButton>
</ElTooltip>
</span>
</template>
</ElPopconfirm>
</div>
</article>
</div>
</ElScrollbar>
<div v-if="paginationVisible" class="execution-list-panel__pagination">
<ElPagination
small
background
layout="total, prev, pager, next"
v-bind="pagination"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped lang="scss">
.execution-list-panel {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 12px;
height: 100%;
padding: 12px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 8px;
background-color: #fff;
}
.execution-list-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
}
.execution-list-panel__title {
margin: 0;
color: rgb(15 23 42 / 94%);
font-size: 16px;
font-weight: 700;
line-height: 1.4;
}
.execution-list-panel__search {
display: flex;
align-items: center;
gap: 8px;
}
.execution-search-input {
width: 140px;
flex: 0 0 auto;
}
.execution-search-input__clear {
color: rgb(192 196 204);
cursor: pointer;
transition: color 0.2s ease;
}
.execution-search-input__clear:hover {
color: rgb(144 147 153);
}
.execution-search__icons {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
margin-left: auto;
}
.execution-search-input__btn {
width: 24px;
min-width: 24px;
height: 24px;
padding: 0;
}
.execution-owner-select {
width: 140px;
flex: 0 0 auto;
}
.execution-search__icons :deep(.el-button + .el-button) {
margin-left: 0;
}
.execution-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.execution-status-grid__item {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
min-height: 40px;
padding: 8px 10px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 6px;
background-color: rgb(248 250 252 / 80%);
color: rgb(51 65 85 / 96%);
cursor: pointer;
transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
}
.execution-status-grid__item:hover {
border-color: rgb(148 163 184 / 80%);
background-color: #fff;
}
.execution-status-grid__item.is-active {
border-color: rgb(64 158 255 / 68%);
background-color: rgb(236 245 255 / 92%);
color: var(--el-color-primary);
}
.execution-status-grid__item span {
overflow: hidden;
min-width: 0;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.execution-status-grid__item strong {
flex: 0 0 auto;
font-size: 16px;
font-weight: 700;
}
.execution-list-panel__scrollbar {
min-height: 0;
flex: 1;
}
.execution-list-panel__empty {
padding: 32px 0;
}
.execution-list-panel__list {
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.execution-list-panel__pagination {
display: flex;
justify-content: flex-end;
padding-top: 4px;
overflow: hidden;
}
.execution-item {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 10px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 6px;
background-color: #fff;
cursor: pointer;
transition:
border-color 0.16s ease,
background-color 0.16s ease;
}
.execution-item:hover {
border-color: rgb(148 163 184 / 76%);
background-color: rgb(248 250 252 / 68%);
}
.execution-item.is-active {
border-color: rgb(64 158 255 / 68%);
background-color: rgb(236 245 255 / 78%);
}
.execution-item__main {
min-width: 0;
flex: 1;
}
.execution-item__top {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
min-height: 24px;
}
.execution-item__status-tag {
flex: 0 0 auto;
}
.execution-item__name {
overflow: hidden;
min-width: 0;
color: rgb(15 23 42 / 94%);
font-size: 14px;
font-weight: 700;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
.execution-item__meta {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 8px;
color: rgb(100 116 139 / 94%);
font-size: 12px;
line-height: 1.5;
}
.execution-item__meta span {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.execution-item__progress-row {
width: 100%;
}
.execution-item__progress {
flex: 1;
min-width: 0;
}
.execution-item__actions {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.execution-item__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.execution-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
@media (width <= 1280px) {
.execution-item {
flex-direction: column;
}
.execution-item__actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import MemberCurrentPanel from './member-current-panel.vue';
import MemberLogPanel from './member-log-panel.vue';
defineOptions({ name: 'ProjectExecutionMemberDialog' });
interface Props {
execution: Api.Project.ProjectExecution | null;
members: Api.Project.ExecutionMember[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManageMember: boolean;
canChangeOwner: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateExecutionMemberParams): void;
(e: 'inactive', member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams): void;
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
type TabName = 'current' | 'log';
const activeTab = ref<TabName>('current');
const currentPanelRef = ref<InstanceType<typeof MemberCurrentPanel> | null>(null);
const dialogTitle = computed(() =>
props.execution ? `执行成员管理:${props.execution.executionName}` : '执行成员管理'
);
const projectId = computed(() => props.execution?.projectId || '');
const executionId = computed(() => props.execution?.id || '');
function handleAdd(payload: Api.Project.CreateExecutionMemberParams) {
emit('add', payload);
}
function handleInactive(member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams) {
emit('inactive', member, payload);
}
function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) {
emit('change-owner', payload);
}
watch(
() => visible.value,
value => {
if (value) {
activeTab.value = 'current';
return;
}
currentPanelRef.value?.reset();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" :show-footer="false" :scrollbar="false">
<ElTabs v-model="activeTab" class="execution-member-dialog__tabs">
<ElTabPane label="当前成员" name="current">
<MemberCurrentPanel
ref="currentPanelRef"
:execution="execution"
:members="members"
:user-options="userOptions"
:loading="loading"
:can-manage-member="canManageMember"
:can-change-owner="canChangeOwner"
@add="handleAdd"
@inactive="handleInactive"
@change-owner="handleChangeOwner"
/>
</ElTabPane>
<ElTabPane label="变更历史" name="log" lazy>
<MemberLogPanel
v-if="projectId && executionId"
:project-id="projectId"
:execution-id="executionId"
:user-options="userOptions"
:active="activeTab === 'log'"
/>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.execution-member-dialog__tabs {
--el-tabs-header-height: 40px;
}
</style>

View File

@@ -0,0 +1,548 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE } from '@/constants/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
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 BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { isActiveExecutionMember } from '../shared';
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);
}
defineOptions({ name: 'ProjectExecutionOperateDialog' });
type OperateMode = 'create' | 'edit' | 'view';
interface Props {
mode: OperateMode;
rowData: Api.Project.ProjectExecution | null;
userOptions: Api.SystemManage.UserSimple[];
currentMembers?: Api.Project.ExecutionMember[];
}
interface Emits {
(e: 'submit', payload: Api.Project.SaveProjectExecutionParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const activeMembers = computed(() => (props.currentMembers ?? []).filter(member => isActiveExecutionMember(member)));
function resolveUserLabel(userId: string | null | undefined, fallbackNickname?: string | null) {
if (!userId) {
return '';
}
return fallbackNickname || props.userOptions.find(item => item.id === userId)?.nickname || userId;
}
function resolveMemberLabel(member: Api.Project.ExecutionMember) {
return resolveUserLabel(member.userId, member.userNickname);
}
const ownerDisplayName = computed(() => resolveUserLabel(props.rowData?.ownerId, props.rowData?.ownerNickname));
const activeMemberIds = computed(() => activeMembers.value.map(member => member.userId));
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const autoOwnerMemberId = ref<string | null>(null);
/** 左栏容器 ref用其高度动态驱动右侧富文本让两栏视觉等高 */
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
useResizeObserver(leftColRef, entries => {
const h = entries[0]?.contentRect.height;
if (h && h > 120) {
// 减去 BusinessFormSection 标题h4大致高度让富文本所在 section 与左栏底对齐
editorHeight.value = `${Math.max(h - 60, 240)}px`;
}
});
function parsePlannedDate(value: string | null | undefined) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
const model = reactive<Api.Project.SaveProjectExecutionParams>({
executionName: '',
executionType: '',
ownerId: '',
projectRequirementId: null,
plannedStartDate: null,
plannedEndDate: null,
executionDesc: null,
memberUserIds: []
});
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
let startDate = parsePlannedDate(model.plannedStartDate);
if (!startDate) {
startDate = new Date();
model.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
}
const endDate = new Date(startDate.getTime());
mutator(endDate);
return endDate;
}
};
}
const plannedEndDateShortcuts = [
buildEndDateShortcut('一星期', date => date.setDate(date.getDate() + 7)),
buildEndDateShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildEndDateShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
buildEndDateShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
buildEndDateShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
buildEndDateShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
const isView = computed(() => props.mode === 'view');
const dialogTitle = computed(() => {
if (props.mode === 'create') {
return '新建执行';
}
if (props.mode === 'view') {
return props.rowData?.executionName ? `查看执行:${props.rowData.executionName}` : '查看执行';
}
return props.rowData?.executionName ? `编辑执行:${props.rowData.executionName}` : '编辑执行';
});
const rules = computed(
() =>
({
executionName: [createRequiredRule('请输入执行名称')],
executionType: [createRequiredRule('请选择执行类型')],
ownerId: [createRequiredRule('请选择执行负责人')],
memberUserIds: props.mode === 'create' ? [createRequiredRule('请选择执行成员')] : [],
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[]>
);
function normalizeMemberUserIds(memberUserIds?: string[]) {
return Array.from(new Set(memberUserIds?.filter(Boolean) ?? []));
}
function getUserRoleName(item: Api.SystemManage.UserSimple) {
return item.deptName || '';
}
function syncOwnerMember(ownerId: string | null, previousOwnerId: string | null = autoOwnerMemberId.value) {
if (props.mode !== 'create') {
return;
}
const currentMemberUserIds = normalizeMemberUserIds(model.memberUserIds);
const memberUserIds = previousOwnerId
? currentMemberUserIds.filter(userId => userId !== previousOwnerId)
: currentMemberUserIds;
model.memberUserIds = ownerId ? normalizeMemberUserIds([...memberUserIds, ownerId]) : memberUserIds;
autoOwnerMemberId.value = ownerId;
}
function ensureOwnerInMembers() {
if (props.mode !== 'create' || !model.ownerId) {
return;
}
model.memberUserIds = normalizeMemberUserIds([...(model.memberUserIds || []), model.ownerId]);
}
async function handleConfirm() {
ensureOwnerInMembers();
await validate();
emit('submit', {
executionName: model.executionName.trim(),
executionType: model.executionType.trim(),
ownerId: model.ownerId,
projectRequirementId: null,
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
executionDesc: isEmptyRichText(model.executionDesc) ? null : (model.executionDesc ?? null),
memberUserIds: props.mode === 'create' ? normalizeMemberUserIds(model.memberUserIds) : undefined
});
}
function handleMemberChange(value: string[]) {
if (props.mode !== 'create') {
return;
}
model.memberUserIds = normalizeMemberUserIds(model.ownerId ? [...value, model.ownerId] : value);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.executionName = props.rowData?.executionName || '';
model.executionType = props.rowData?.executionType || '';
model.ownerId = props.rowData?.ownerId || '';
model.projectRequirementId = null;
model.plannedStartDate = props.rowData?.plannedStartDate || null;
model.plannedEndDate = props.rowData?.plannedEndDate || null;
model.executionDesc = props.rowData?.executionDesc || null;
model.memberUserIds = [];
autoOwnerMemberId.value = null;
await nextTick();
formRef.value?.clearValidate();
}
);
watch(
() => model.ownerId,
(ownerId, previousOwnerId) => {
syncOwnerMember(ownerId || null, previousOwnerId || null);
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
width="1100px"
max-body-height="78vh"
@confirm="handleConfirm"
>
<template v-if="isView" #footer="{ close }">
<ElButton type="primary" @click="close">关闭</ElButton>
</template>
<ElForm
ref="formRef"
:model="model"
:rules="rules"
label-position="top"
:validate-on-rule-change="false"
class="execution-operate-dialog__form"
:class="{ 'is-view': isView }"
>
<div class="execution-operate-dialog__grid">
<div ref="leftColRef" class="execution-operate-dialog__col-left">
<BusinessFormSection title="执行信息">
<ElFormItem label="执行名称" prop="executionName">
<ElInput v-model="model.executionName" :readonly="isView" maxlength="200" placeholder="请输入执行名称" />
</ElFormItem>
<ElFormItem label="执行类型" prop="executionType">
<DictSelect
v-model="model.executionType"
:dict-code="RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE"
:disabled="isView"
filterable
placeholder="请选择执行类型"
/>
</ElFormItem>
<ElFormItem v-if="mode === 'create'" label="负责人" prop="ownerId">
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
</ElFormItem>
<ElFormItem v-else>
<template #label>
<template v-if="isView">负责人</template>
<span v-else class="business-form-label-with-tip">
<ElTooltip
content="如需变更负责人,请关闭此弹层后点击列表「负责人」按钮。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>负责人</span>
</span>
</template>
<ElInput
:model-value="ownerDisplayName"
readonly
class="execution-operate-dialog__readonly-input"
placeholder="未设置负责人"
/>
</ElFormItem>
<ElFormItem v-if="mode === 'create'" label="执行成员" prop="memberUserIds">
<ElSelect
v-model="model.memberUserIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="请选择执行成员"
@change="handleMemberChange"
>
<ElOption
v-for="item in userOptions"
:key="item.id"
:label="item.id === model.ownerId ? `${item.nickname}(负责人)` : item.nickname"
:value="item.id"
:disabled="item.id === model.ownerId"
>
<div class="execution-member-option">
<span class="execution-member-option__name">
{{ item.nickname }}
<span v-if="item.id === model.ownerId" class="execution-member-option__owner">负责人</span>
</span>
<span v-if="getUserRoleName(item)" class="execution-member-option__role">
{{ getUserRoleName(item) }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem v-else>
<template #label>
<template v-if="isView">执行成员</template>
<span v-else class="business-form-label-with-tip">
<ElTooltip
content="如需调整成员,请关闭此弹层后点击列表「成员」按钮。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>执行成员</span>
</span>
</template>
<ElSelect
:model-value="activeMemberIds"
multiple
disabled
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="暂无在岗成员"
>
<ElOption
v-for="member in activeMembers"
:key="member.id"
:label="resolveMemberLabel(member)"
:value="member.userId"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择计划开始日期"
:disabled="isView"
class="execution-operate-dialog__date-picker"
/>
</ElFormItem>
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择计划结束日期"
:shortcuts="isView ? undefined : plannedEndDateShortcuts"
:disabled="isView"
class="execution-operate-dialog__date-picker"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="execution-operate-dialog__col-right">
<BusinessFormSection title="执行说明">
<ElFormItem class="execution-operate-dialog__desc-item">
<BusinessRichTextEditor
v-model="model.executionDesc"
:disabled="isView"
:height="editorHeight"
upload-directory="execution"
placeholder="请输入执行说明"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.execution-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.execution-operate-dialog__col-left,
.execution-operate-dialog__col-right {
min-width: 0;
}
.execution-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.execution-operate-dialog__desc-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.execution-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
:deep(.execution-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
:deep(.execution-operate-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.execution-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.execution-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.execution-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
.execution-operate-dialog__form.is-view :deep(.el-input__wrapper),
.execution-operate-dialog__form.is-view :deep(.el-select__wrapper),
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
.execution-operate-dialog__form.is-view :deep(.el-input__wrapper:hover),
.execution-operate-dialog__form.is-view :deep(.el-input__wrapper.is-focus),
.execution-operate-dialog__form.is-view :deep(.el-select__wrapper:hover),
.execution-operate-dialog__form.is-view :deep(.el-select__wrapper.is-focused),
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner:hover),
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
.execution-operate-dialog__form.is-view :deep(.el-input__inner),
.execution-operate-dialog__form.is-view :deep(.el-select__placeholder),
.execution-operate-dialog__form.is-view :deep(.el-select__selected-item),
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
.execution-member-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.execution-member-option__name {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
overflow: hidden;
color: rgb(15 23 42 / 94%);
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.execution-member-option__owner {
flex: 0 0 auto;
color: var(--el-color-primary);
font-size: 12px;
font-weight: 500;
}
.execution-member-option__role {
flex: 0 0 auto;
max-width: 48%;
overflow: hidden;
color: rgb(100 116 139 / 88%);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime, isActiveExecutionMember } from '../shared';
defineOptions({ name: 'ProjectExecutionMemberCurrentPanel' });
interface Props {
execution: Api.Project.ProjectExecution | null;
members: Api.Project.ExecutionMember[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManageMember: boolean;
canChangeOwner: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateExecutionMemberParams): void;
(e: 'inactive', member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams): void;
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const newMemberId = ref('');
const PAGE_SIZE = 5;
const currentPage = ref(1);
const pagedMembers = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return props.members.slice(start, start + PAGE_SIZE);
});
watch(
() => props.members.length,
total => {
const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
}
}
);
const { createRequiredRule } = useFormRules();
const inactiveTarget = ref<Api.Project.ExecutionMember | null>(null);
const inactiveModel = reactive({ reason: '' });
const { formRef: inactiveFormRef, validate: validateInactive } = useForm();
const inactiveRules = {
reason: [createRequiredRule('请输入失效原因')]
} satisfies Record<string, App.Global.FormRule[]>;
const inactiveVisible = computed({
get: () => Boolean(inactiveTarget.value),
set: value => {
if (!value) {
inactiveTarget.value = null;
}
}
});
const ownerTarget = ref<Api.Project.ExecutionMember | null>(null);
const ownerModel = reactive({ reason: '' });
const ownerVisible = computed({
get: () => Boolean(ownerTarget.value),
set: value => {
if (!value) {
ownerTarget.value = null;
}
}
});
const currentOwnerId = computed(() => props.execution?.ownerId || '');
function isOwner(member: Api.Project.ExecutionMember) {
return Boolean(currentOwnerId.value) && member.userId === currentOwnerId.value;
}
function isActiveMember(member: Api.Project.ExecutionMember) {
return isActiveExecutionMember(member);
}
const activeMemberUserIds = computed(() =>
props.members.filter(item => isActiveExecutionMember(item)).map(item => item.userId)
);
const userNicknameMap = computed(() => {
const map = new Map<string, string>();
props.userOptions.forEach(item => {
if (item.id) {
map.set(item.id, item.nickname?.trim() || item.id);
}
});
return map;
});
function getMemberIndex(index: number) {
return (currentPage.value - 1) * PAGE_SIZE + index + 1;
}
function getMemberDisplayName(member: Api.Project.ExecutionMember | null) {
if (!member) return '';
return member.userNickname?.trim() || userNicknameMap.value.get(member.userId) || member.userId || '--';
}
function buildMemberActions(row: Api.Project.ExecutionMember): BusinessTableAction[] {
const actions: BusinessTableAction[] = [];
if (props.canChangeOwner) {
actions.push({
key: 'set-owner',
label: '设为负责人',
buttonType: 'primary',
onClick: () => openOwner(row)
});
}
if (props.canManageMember) {
actions.push({
key: 'inactive',
label: '失效',
buttonType: 'danger',
onClick: () => openInactive(row)
});
}
return actions;
}
function handleAdd() {
if (!newMemberId.value) {
window.$message?.warning('请选择成员用户');
return;
}
emit('add', { userId: newMemberId.value });
newMemberId.value = '';
}
async function openInactive(member: Api.Project.ExecutionMember) {
inactiveTarget.value = member;
inactiveModel.reason = '';
await nextTick();
inactiveFormRef.value?.clearValidate();
}
async function confirmInactive() {
await validateInactive();
if (!inactiveTarget.value) {
return;
}
emit('inactive', inactiveTarget.value, { reason: inactiveModel.reason.trim() });
inactiveTarget.value = null;
}
function openOwner(member: Api.Project.ExecutionMember) {
ownerTarget.value = member;
ownerModel.reason = '';
}
function confirmOwner() {
if (!ownerTarget.value) {
return;
}
emit('change-owner', {
newOwnerId: ownerTarget.value.userId,
reason: ownerModel.reason.trim() || null
});
ownerTarget.value = null;
}
function reset() {
newMemberId.value = '';
inactiveTarget.value = null;
inactiveModel.reason = '';
ownerTarget.value = null;
ownerModel.reason = '';
currentPage.value = 1;
}
defineExpose({ reset });
</script>
<template>
<div v-loading="loading" class="member-current-panel">
<div v-if="canManageMember" class="member-current-panel__toolbar">
<BusinessUserSelect
v-model="newMemberId"
:options="userOptions"
:exclude-user-ids="activeMemberUserIds"
no-data-text="所有项目成员已加入执行"
placeholder="选择用户加入执行"
class="member-current-panel__user-select"
/>
<ElButton type="primary" @click="handleAdd">新增成员</ElButton>
</div>
<ElTable :data="pagedMembers" :height="247" border row-key="id" size="default">
<ElTableColumn type="index" :index="getMemberIndex" label="序号" width="64" align="center" />
<ElTableColumn label="成员" width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="member-current-panel__name">{{ getMemberDisplayName(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="角色" width="140" align="center">
<template #default="{ row }">
<ElTag v-if="isOwner(row)" type="warning" effect="light">负责人</ElTag>
<ElTag v-else type="info" effect="plain">成员</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="加入时间" min-width="200" align="center">
<template #default="{ row }">
{{ formatDateTime(row.joinedAt) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<BusinessTableActionCell v-if="!isOwner(row) && isActiveMember(row)" :actions="buildMemberActions(row)" />
<span v-else class="member-current-panel__actions-empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="当前执行暂无成员" :image-size="80" />
</template>
</ElTable>
<div class="member-current-panel__pagination">
<ElPagination
v-if="members.length > PAGE_SIZE"
v-model:current-page="currentPage"
:page-size="PAGE_SIZE"
:total="members.length"
layout="total, prev, pager, next"
background
small
/>
</div>
<BusinessFormDialog
v-model="inactiveVisible"
:title="`失效成员:${getMemberDisplayName(inactiveTarget)}`"
preset="sm"
append-to-body
@confirm="confirmInactive"
>
<ElForm
ref="inactiveFormRef"
:model="inactiveModel"
:rules="inactiveRules"
label-position="top"
:validate-on-rule-change="false"
>
<ElFormItem label="失效原因" prop="reason">
<ElInput
v-model="inactiveModel.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入失效原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
<BusinessFormDialog
v-model="ownerVisible"
:title="`设为负责人:${getMemberDisplayName(ownerTarget)}`"
preset="sm"
append-to-body
@confirm="confirmOwner"
>
<ElForm :model="ownerModel" label-position="top">
<ElFormItem label="变更原因">
<ElInput
v-model="ownerModel.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="可选填写变更原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</div>
</template>
<style scoped lang="scss">
.member-current-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-current-panel__toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.member-current-panel__user-select {
width: 280px;
}
.member-current-panel__name {
display: inline-block;
max-width: 100%;
overflow: hidden;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.member-current-panel__actions-empty {
color: var(--el-text-color-placeholder);
}
.member-current-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { fetchGetProjectExecutionMemberLogPage } from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime, getExecutionMemberActionName, getExecutionMemberActionTagType } from '../shared';
defineOptions({ name: 'ProjectExecutionMemberLogPanel' });
interface Props {
projectId: string;
executionId: string;
userOptions: Api.SystemManage.UserSimple[];
active: boolean;
}
const props = defineProps<Props>();
type ActionType = Api.Project.ExecutionMemberActionType;
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: ActionType }> = [
{ label: getExecutionMemberActionName('join'), value: 'join' },
{ label: getExecutionMemberActionName('inactive'), value: 'inactive' },
{ label: getExecutionMemberActionName('owner_transfer_in'), value: 'owner_transfer_in' },
{ label: getExecutionMemberActionName('owner_transfer_out'), value: 'owner_transfer_out' }
];
const searchParams = reactive<{
pageNo: number;
pageSize: number;
actionTypes?: ActionType[];
userId?: string;
}>({
pageNo: 1,
pageSize: 5,
actionTypes: undefined,
userId: undefined
});
const canLoad = computed(() => Boolean(props.projectId && props.executionId));
type LogPageResponse = Awaited<ReturnType<typeof fetchGetProjectExecutionMemberLogPage>>;
function buildRequestParams(): Api.Project.ExecutionMemberLogSearchParams {
return {
pageNo: searchParams.pageNo,
pageSize: searchParams.pageSize,
actionTypes: searchParams.actionTypes?.length ? searchParams.actionTypes : undefined,
userId: searchParams.userId || undefined
};
}
function transformLogPage(response: LogPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
LogPageResponse,
Api.Project.ExecutionMemberLog
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => {
if (!canLoad.value) {
return Promise.resolve({
data: { total: 0, list: [] },
error: null
} as unknown as LogPageResponse);
}
return fetchGetProjectExecutionMemberLogPage(props.projectId, props.executionId, buildRequestParams());
},
transform: response => transformLogPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 5),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 5;
},
immediate: false,
columns: () => [{ prop: 'actionTime', label: '时间' }]
});
// 每次切到该 tab 都按当前筛选条件重拉第 1 页,确保用户在"当前成员" tab 操作后回到这里能看到最新事件
watch(
() => props.active,
active => {
if (active && canLoad.value) {
getDataByPage(1);
}
},
{ immediate: true }
);
// 切换到不同执行项时完全清空筛选条件
watch(
() => props.executionId,
() => {
resetSearchParams();
}
);
function resetSearchParams() {
searchParams.pageNo = 1;
searchParams.actionTypes = undefined;
searchParams.userId = undefined;
}
async function handleSearch() {
await getDataByPage(1);
}
async function handleReset() {
resetSearchParams();
await getDataByPage(1);
}
function getMemberDisplay(row: Api.Project.ExecutionMemberLog) {
return row.userNicknameSnapshot?.trim() || row.userId || '--';
}
function getOperatorDisplay(row: Api.Project.ExecutionMemberLog) {
return row.operatorNicknameSnapshot?.trim() || row.operatorUserId || '--';
}
function refresh() {
return getDataByPage(1);
}
defineExpose({ refresh });
</script>
<template>
<div class="member-log-panel">
<div class="member-log-panel__toolbar">
<ElSelect
v-model="searchParams.actionTypes"
multiple
collapse-tags
collapse-tags-tooltip
clearable
placeholder="全部事件"
class="member-log-panel__action-select"
>
<ElOption v-for="item in ACTION_TYPE_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
<BusinessUserSelect
v-model="searchParams.userId"
:options="userOptions"
placeholder="全部成员"
clearable
class="member-log-panel__user-select"
/>
<div class="member-log-panel__actions">
<ElButton @click="handleReset">重置</ElButton>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
</div>
</div>
<ElTable v-loading="loading" :data="data" :height="247" border size="default">
<ElTableColumn label="时间" width="170" align="center">
<template #default="{ row }">
{{ formatDateTime(row.actionTime) }}
</template>
</ElTableColumn>
<ElTableColumn label="事件类型" width="130" align="center">
<template #default="{ row }">
<ElTag :type="getExecutionMemberActionTagType(row.actionType)" effect="light">
{{ getExecutionMemberActionName(row.actionType) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="成员" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ getMemberDisplay(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ getOperatorDisplay(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="原因" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.reason">{{ row.reason }}</span>
<span v-else class="member-log-panel__empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="暂无变更记录" :image-size="80" />
</template>
</ElTable>
<div class="member-log-panel__pagination">
<ElPagination
v-if="mobilePagination.total"
background
layout="total, prev, pager, next"
small
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.member-log-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-log-panel__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.member-log-panel__action-select {
width: 200px;
}
.member-log-panel__user-select {
width: 200px;
}
.member-log-panel__actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.member-log-panel__empty {
color: var(--el-text-color-placeholder);
}
.member-log-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,75 @@
<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: 'ProjectExecutionStatusActionDialog' });
interface Props {
title: string;
action: Api.Project.LifecycleAction<string> | 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="title" 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,242 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Edit, Flag, User } from '@element-plus/icons-vue';
import { formatDate, getProgressText, getTaskStatusName } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskBoardView' });
interface Props {
data: Api.Project.ProjectTask[];
loading: boolean;
statusBoard: Api.Project.StatusBoard | null;
canUpdate: boolean;
canChangeStatus: boolean;
}
interface Emits {
(e: 'detail', row: Api.Project.ProjectTask): void;
(e: 'edit', row: Api.Project.ProjectTask): void;
(
e: 'status-action',
row: Api.Project.ProjectTask,
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode> | null
): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const groupedTasks = computed(() => {
const map = new Map<string, Api.Project.ProjectTask[]>();
const items = props.statusBoard?.items ?? [];
items.forEach(item => {
map.set(item.statusCode, []);
});
props.data.forEach(task => {
const list = map.get(task.statusCode);
if (list) {
list.push(task);
}
});
return items.map(item => ({
statusCode: item.statusCode,
title: item.statusName,
count: item.count,
tasks: map.get(item.statusCode) || []
}));
});
function getFirstAction(row: Api.Project.ProjectTask) {
return row.availableActions[0] || null;
}
</script>
<template>
<ElCard class="task-board-card" body-class="task-board-card__body">
<ElSkeleton v-if="loading" :rows="6" animated />
<div v-else class="task-board">
<section v-for="column in groupedTasks" :key="column.statusCode" class="task-board-column">
<header class="task-board-column__header">
<strong>{{ column.title }}</strong>
<ElTag effect="plain" size="small">{{ column.count }}</ElTag>
</header>
<ElScrollbar class="task-board-column__scrollbar">
<ElEmpty v-if="column.tasks.length === 0" :description="`暂无${column.title}任务`" />
<template v-else>
<article
v-for="task in column.tasks"
:key="task.id"
class="task-board-card-item"
@click="emit('detail', task)"
>
<div class="task-board-card-item__top">
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
<ElTag effect="plain" size="small">{{ getTaskStatusName(task) }}</ElTag>
</div>
<div class="task-board-card-item__meta">
<span>
<ElIcon><User /></ElIcon>
{{ task.ownerNickname || task.ownerId || '未设置负责人' }}
</span>
<span>
<ElIcon><Flag /></ElIcon>
计划结束 {{ formatDate(task.plannedEndDate) }}
</span>
</div>
<div class="task-board-card-item__progress">
<span>进度 {{ getProgressText(task.progressRate) }}</span>
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
</div>
<div class="task-board-card-item__actions" @click.stop>
<ElButton v-if="canUpdate" size="small" plain :icon="Edit" @click="emit('edit', task)">编辑</ElButton>
<ElButton
v-if="canChangeStatus && getFirstAction(task)"
size="small"
type="primary"
plain
@click="emit('status-action', task, getFirstAction(task)!)"
>
{{ getFirstAction(task)!.actionName }}
</ElButton>
<ElButton
v-else-if="canChangeStatus"
size="small"
type="primary"
plain
@click="emit('status-action', task, null)"
>
状态
</ElButton>
</div>
</article>
</template>
</ElScrollbar>
</section>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.task-board-card {
min-height: 0;
flex: 1;
}
:deep(.task-board-card__body) {
display: flex;
min-height: 0;
height: 100%;
}
.task-board {
display: grid;
grid-template-columns: repeat(5, minmax(220px, 1fr));
gap: 12px;
width: 100%;
min-height: 0;
overflow-x: auto;
}
.task-board-column {
display: flex;
min-width: 220px;
min-height: 0;
flex-direction: column;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 8px;
background-color: rgb(248 250 252 / 82%);
}
.task-board-column__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 42px;
padding: 10px 12px;
border-bottom: 1px solid rgb(226 232 240 / 92%);
color: rgb(15 23 42 / 94%);
}
.task-board-column__scrollbar {
min-height: 0;
flex: 1;
padding: 10px;
}
.task-board-card-item {
padding: 10px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 6px;
margin-bottom: 10px;
background-color: #fff;
cursor: pointer;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.task-board-card-item:hover {
border-color: rgb(148 163 184 / 76%);
box-shadow: 0 6px 18px rgb(15 23 42 / 8%);
}
.task-board-card-item__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.task-board-card-item__title {
min-width: 0;
color: rgb(15 23 42 / 94%);
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.task-board-card-item__meta {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
color: rgb(100 116 139 / 94%);
font-size: 12px;
}
.task-board-card-item__meta span {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.task-board-card-item__progress {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
color: rgb(71 85 105 / 96%);
font-size: 12px;
}
.task-board-card-item__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.task-board-card-item__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
</style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { formatDate, formatDateTime, getProgressText, getTaskStatusName } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskDetailDialog' });
interface Props {
rowData: Api.Project.ProjectTask | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const detailItems = computed(() => {
const row = props.rowData;
return [
{ label: '任务名称', value: row?.taskTitle || '--' },
{ label: '状态', value: row ? getTaskStatusName(row) : '--' },
{ label: '负责人', value: row?.ownerNickname || row?.ownerId || '--' },
{ label: '进度', value: getProgressText(row?.progressRate) },
{ label: '计划开始日期', value: formatDate(row?.plannedStartDate) },
{ label: '计划结束日期', value: formatDate(row?.plannedEndDate) },
{ label: '实际开始日期', value: formatDate(row?.actualStartDate) },
{ label: '实际结束日期', value: formatDate(row?.actualEndDate) },
{ label: '最近更新', value: formatDateTime(row?.updateTime) },
{ label: '状态原因', value: row?.lastStatusReason || '--', span: 2 },
{ label: '任务说明', value: row?.taskDesc || '--', span: 2 }
];
});
</script>
<template>
<BusinessFormDialog v-model="visible" title="任务详情" preset="md" :show-footer="false">
<BusinessFormSection title="任务信息">
<ElDescriptions :column="2" border>
<ElDescriptionsItem v-for="item in detailItems" :key="item.label" :label="item.label" :span="item.span || 1">
<span class="task-detail-text">{{ item.value }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</BusinessFormSection>
</BusinessFormDialog>
</template>
<style scoped>
.task-detail-text {
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,364 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { useForm, useFormRules } from '@/hooks/common/form';
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 BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProjectExecutionTaskOperateDialog' });
type OperateMode = 'create' | 'edit';
export interface PlannedEndShortcutOffset {
text: string;
days?: number;
months?: number;
years?: number;
}
interface Props {
mode: OperateMode;
rowData: Api.Project.ProjectTask | null;
userOptions: Api.SystemManage.UserSimple[];
taskOptions: Api.Project.ProjectTask[];
plannedEndShortcuts?: PlannedEndShortcutOffset[];
}
interface Emits {
(e: 'submit', payload: Api.Project.SaveProjectTaskParams): void;
}
const props = withDefaults(defineProps<Props>(), {
plannedEndShortcuts: () => [
{ text: '三天', days: 3 },
{ text: '一星期', days: 7 },
{ text: '两星期', days: 14 },
{ text: '一个月', months: 1 },
{ text: '三个月', months: 3 }
]
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
interface FormModel {
parentTaskId: string | null;
taskTitle: string;
ownerId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
assigneeUserIds: string[];
}
const model = reactive<FormModel>({
parentTaskId: null,
taskTitle: '',
ownerId: null,
plannedStartDate: null,
plannedEndDate: null,
taskDesc: null,
assigneeUserIds: []
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新建任务' : '编辑任务'));
const selectableParentTasks = computed(() => props.taskOptions.filter(item => item.id !== props.rowData?.id));
/** 左栏容器 ref用其高度动态驱动右侧富文本让两栏视觉等高 */
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
useResizeObserver(leftColRef, entries => {
const h = entries[0]?.contentRect.height;
if (h && h > 120) {
editorHeight.value = `${Math.max(h - 60, 240)}px`;
}
});
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);
}
const rules = computed(
() =>
({
taskTitle: [createRequiredRule('请输入任务名称')],
ownerId: model.parentTaskId ? [] : [createRequiredRule('请选择负责人')],
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[]>
);
function parsePlannedDate(value: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
function applyOffset(date: Date, offset: PlannedEndShortcutOffset) {
if (offset.days) {
date.setDate(date.getDate() + offset.days);
}
if (offset.months) {
date.setMonth(date.getMonth() + offset.months);
}
if (offset.years) {
date.setFullYear(date.getFullYear() + offset.years);
}
}
const plannedEndDateShortcuts = computed(() =>
props.plannedEndShortcuts.map(offset => ({
text: offset.text,
value: () => {
let startDate = parsePlannedDate(model.plannedStartDate);
if (!startDate) {
startDate = new Date();
model.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
}
const endDate = new Date(startDate.getTime());
applyOffset(endDate, offset);
return endDate;
}
}))
);
function normalizeAssigneeIds(ids: string[]) {
return Array.from(new Set(ids.filter(id => id && id !== model.ownerId)));
}
async function handleConfirm() {
await validate();
const payload: Api.Project.SaveProjectTaskParams = {
parentTaskId: model.parentTaskId || null,
taskTitle: model.taskTitle.trim(),
ownerId: model.ownerId || null,
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null)
};
if (props.mode === 'create') {
payload.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
}
emit('submit', payload);
}
function handleAssigneeChange(value: string[]) {
model.assigneeUserIds = normalizeAssigneeIds(value);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.parentTaskId = props.rowData?.parentTaskId || null;
model.taskTitle = props.rowData?.taskTitle || '';
model.ownerId = props.rowData?.ownerId || null;
model.plannedStartDate = props.rowData?.plannedStartDate || null;
model.plannedEndDate = props.rowData?.plannedEndDate || null;
model.taskDesc = props.rowData?.taskDesc || null;
model.assigneeUserIds = [];
await nextTick();
formRef.value?.clearValidate();
}
);
watch(
() => model.ownerId,
() => {
if (props.mode === 'create') {
model.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
width="1100px"
max-body-height="78vh"
@confirm="handleConfirm"
>
<ElForm
ref="formRef"
:model="model"
:rules="rules"
label-position="top"
:validate-on-rule-change="false"
class="task-operate-dialog__form"
>
<div class="task-operate-dialog__grid">
<div ref="leftColRef" class="task-operate-dialog__col-left">
<BusinessFormSection title="任务信息">
<ElFormItem label="任务名称" prop="taskTitle">
<ElInput v-model="model.taskTitle" maxlength="200" placeholder="请输入任务名称" />
</ElFormItem>
<ElFormItem label="父任务">
<ElSelect v-model="model.parentTaskId" clearable filterable class="w-full" placeholder="请选择父任务">
<ElOption
v-for="item in selectableParentTasks"
:key="item.id"
:label="item.taskTitle"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="负责人" prop="ownerId">
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
</ElFormItem>
<ElFormItem v-if="mode === 'create'" label="协办人" prop="assigneeUserIds">
<ElSelect
v-model="model.assigneeUserIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="请选择协办人"
@change="handleAssigneeChange"
>
<ElOption
v-for="item in userOptions"
:key="item.id"
:label="item.id === model.ownerId ? `${item.nickname}(负责人)` : item.nickname"
:value="item.id"
:disabled="item.id === model.ownerId"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择计划开始日期"
class="task-operate-dialog__date-picker"
/>
</ElFormItem>
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择计划结束日期"
:shortcuts="plannedEndDateShortcuts"
class="task-operate-dialog__date-picker"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="task-operate-dialog__col-right">
<BusinessFormSection title="任务说明">
<ElFormItem class="task-operate-dialog__desc-item">
<BusinessRichTextEditor
v-model="model.taskDesc"
:height="editorHeight"
upload-directory="task"
placeholder="请输入任务说明"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.task-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.task-operate-dialog__col-left,
.task-operate-dialog__col-right {
min-width: 0;
}
.task-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.task-operate-dialog__desc-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.task-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
:deep(.task-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ProjectExecutionTaskSearch' });
interface Props {
model: Api.Project.ProjectTaskSearchParams;
userOptions: Api.SystemManage.UserSimple[];
statusOptions: Api.Project.StatusBoardItem[];
disabled?: boolean;
}
interface Emits {
(e: 'search'): void;
(e: 'reset'): void;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
});
const emit = defineEmits<Emits>();
const ownerOptions = computed(() =>
props.userOptions.map(item => ({
label: item.nickname,
value: item.id
}))
);
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '关键词',
type: 'input',
placeholder: '任务名称/说明'
},
{
key: 'statusCode',
label: '状态',
type: 'select',
options: props.statusOptions.map(item => ({
label: item.statusName,
value: item.statusCode
})),
placeholder: '全部状态'
},
{
key: 'ownerId',
label: '负责人',
type: 'select',
options: ownerOptions.value,
placeholder: '全部负责人'
},
{
key: 'updateTime',
label: '更新时间',
type: 'dateRange'
}
]);
</script>
<template>
<TableSearchFields
:model-value="model"
:fields="fields"
:columns="4"
:disabled="disabled"
@search="emit('search')"
@reset="emit('reset')"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { PaginationProps } from 'element-plus';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskTableView' });
interface Props {
data: Api.Project.ProjectTask[];
loading: boolean;
pagination: Partial<PaginationProps & Record<string, any>>;
canUpdate: boolean;
canChangeStatus: boolean;
}
interface Emits {
(e: 'detail', row: Api.Project.ProjectTask): void;
(e: 'edit', row: Api.Project.ProjectTask): void;
(
e: 'status-action',
row: Api.Project.ProjectTask,
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode> | null
): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const paginationVisible = computed(() => Boolean(props.pagination.total));
const taskTitleMap = computed(() => {
const map = new Map<string, string>();
props.data.forEach(item => {
if (item.id && item.taskTitle) {
map.set(item.id, item.taskTitle);
}
});
return map;
});
function getParentTaskLabel(parentTaskId: string | null) {
if (!parentTaskId) {
return '--';
}
return taskTitleMap.value.get(parentTaskId) || '--';
}
function createActions(row: Api.Project.ProjectTask): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
onClick: () => emit('detail', row)
}
];
if (props.canUpdate) {
actions.push({
key: 'edit',
label: '编辑',
onClick: () => emit('edit', row)
});
}
if (!props.canChangeStatus) {
return actions;
}
if (!row.availableActions.length) {
return [
...actions,
{
key: 'status',
label: '状态',
buttonType: 'primary',
onClick: () => emit('status-action', row, null)
}
];
}
return [
...actions,
...row.availableActions.map(action => ({
key: `status-${action.actionCode}`,
label: action.actionName,
buttonType: 'primary' as const,
onClick: () => emit('status-action', row, action)
}))
];
}
function handlePageChange(page: number) {
props.pagination['current-change']?.(page);
}
function handleSizeChange(pageSize: number) {
props.pagination['size-change']?.(pageSize);
}
</script>
<template>
<ElCard class="task-table-card" body-class="business-table-card-body">
<div class="flex-1">
<ElTable
v-loading="loading"
:data="data"
height="100%"
border
row-key="id"
highlight-current-row
@row-dblclick="row => emit('detail', row)"
>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="任务名称" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<ElButton link type="primary" class="task-title-link" @click="emit('detail', row)">
{{ row.taskTitle || '--' }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag effect="plain" :type="getTaskStatusTagType(row.statusCode)">{{ getTaskStatusName(row) }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="负责人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
</ElTableColumn>
<ElTableColumn label="父任务" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
</ElTableColumn>
<ElTableColumn label="进度" width="150">
<template #default="{ row }">
<div class="task-table-progress">
<ElProgress :percentage="row.progressRate" :stroke-width="6" />
</div>
</template>
</ElTableColumn>
<ElTableColumn label="计划周期" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}</template>
</ElTableColumn>
<ElTableColumn label="实际周期" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ formatDateRange(row.actualStartDate, row.actualEndDate) }}</template>
</ElTableColumn>
<ElTableColumn label="最近更新" width="170">
<template #default="{ row }">{{ formatDateTime(row.updateTime) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="220" fixed="right" align="center" class-name="task-operate-column">
<template #default="{ row }">
<BusinessTableActionCell :actions="createActions(row)" />
</template>
</ElTableColumn>
</ElTable>
</div>
<div v-if="paginationVisible" class="task-table-pagination">
<ElPagination
background
layout="total, sizes, prev, pager, next, jumper"
v-bind="pagination"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.task-table-card {
min-height: 0;
flex: 1;
}
.task-title-link {
max-width: 100%;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
.task-table-progress {
width: 120px;
}
.task-table-pagination {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
:deep(.el-table__row.current-row > td.el-table__cell) {
background-color: rgb(64 158 255 / 8%);
}
:deep(.task-operate-column) {
background-color: var(--el-bg-color, #ffffff) !important;
}
:deep(.el-table__row.current-row) {
.task-operate-column {
background-color: var(--el-bg-color, #ffffff) !important;
}
td.el-table__cell.is-fixed-right {
background-color: var(--el-bg-color, #ffffff) !important;
}
}
</style>

View File

@@ -0,0 +1,444 @@
<script setup lang="ts">
import { computed, markRaw, reactive, ref, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import {
fetchChangeProjectTaskStatus,
fetchCreateProjectTask,
fetchGetProjectTask,
fetchGetProjectTaskPage,
fetchGetProjectTaskStatusBoard,
fetchUpdateProjectTask
} from '@/service/api/project';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { shouldRequireTaskProgressBeforeComplete } from '../shared';
import StatusActionDialog from './status-action-dialog.vue';
import TaskBoardView from './task-board-view.vue';
import TaskDetailDialog from './task-detail-dialog.vue';
import TaskOperateDialog from './task-operate-dialog.vue';
import TaskSearch from './task-search.vue';
import TaskTableView from './task-table-view.vue';
import IconMdiViewColumnOutline from '~icons/mdi/view-column-outline';
import IconMdiTableLarge from '~icons/mdi/table-large';
defineOptions({ name: 'ProjectExecutionTaskWorkspace' });
type ViewMode = 'table' | 'board';
type OperateMode = 'create' | 'edit';
type TaskPageResponse = Awaited<ReturnType<typeof fetchGetProjectTaskPage>>;
type TaskStatusAction = Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>;
interface Props {
projectId: string;
execution: Api.Project.ProjectExecution | null;
ownerOptions: Api.SystemManage.UserSimple[];
canCreate: boolean;
canUpdate: boolean;
canChangeStatus: boolean;
}
const props = defineProps<Props>();
const viewMode = ref<ViewMode>('table');
const viewModeOptions = [
{ label: '表格', value: 'table', icon: markRaw(IconMdiTableLarge) },
{ label: '看板', value: 'board', icon: markRaw(IconMdiViewColumnOutline) }
];
const operateVisible = ref(false);
const detailVisible = ref(false);
const statusActionVisible = ref(false);
const operateMode = ref<OperateMode>('create');
const currentTask = ref<Api.Project.ProjectTask | null>(null);
const currentStatusAction = ref<TaskStatusAction | null>(null);
const taskStatusBoard = ref<Api.Project.StatusBoard | null>(null);
const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
pageNo: 1,
pageSize: 10,
keyword: '',
parentTaskId: undefined,
ownerId: undefined,
statusCode: undefined,
updateTime: undefined
});
const executionId = computed(() => props.execution?.id || '');
const canLoadTasks = computed(() => Boolean(props.projectId && executionId.value));
const statusActionTitle = computed(() =>
currentStatusAction.value ? `任务状态变更:${currentStatusAction.value.actionName}` : '任务状态变更'
);
function createRequestParams(): Api.Project.ProjectTaskSearchParams {
return {
...searchParams,
keyword: searchParams.keyword?.trim() || undefined
};
}
function createStatusBoardParams(): Api.Project.ProjectTaskStatusBoardParams {
return {
keyword: searchParams.keyword?.trim() || undefined,
parentTaskId: searchParams.parentTaskId,
ownerId: searchParams.ownerId,
updateTime: searchParams.updateTime
};
}
function transformTaskPage(response: TaskPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const { data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
TaskPageResponse,
Api.Project.ProjectTask
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => {
if (!canLoadTasks.value) {
return Promise.resolve({
data: { total: 0, list: [] },
error: null
} as unknown as TaskPageResponse);
}
return fetchGetProjectTaskPage(props.projectId, executionId.value, createRequestParams());
},
transform: response => transformTaskPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [{ prop: 'taskTitle', label: '任务名称', minWidth: 160 }]
});
const taskOptions = computed(() => data.value);
function resetSearchParams() {
searchParams.pageNo = 1;
searchParams.keyword = '';
searchParams.parentTaskId = undefined;
searchParams.ownerId = undefined;
searchParams.statusCode = undefined;
searchParams.updateTime = undefined;
}
async function handleSearch() {
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
}
async function handleReset() {
resetSearchParams();
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
}
function handleCreate() {
if (!props.execution) {
window.$message?.warning('请先选择执行项');
return;
}
operateMode.value = 'create';
currentTask.value = null;
operateVisible.value = true;
}
async function getTaskDetail(row: Api.Project.ProjectTask) {
if (!props.execution) {
return row;
}
const result = await fetchGetProjectTask(props.projectId, props.execution.id, row.id);
return result.error || !result.data ? row : result.data;
}
async function handleEdit(row: Api.Project.ProjectTask) {
const detail = await getTaskDetail(row);
if (!detail.allowEdit) {
window.$message?.warning('当前任务状态不允许编辑');
return;
}
operateMode.value = 'edit';
currentTask.value = detail;
operateVisible.value = true;
}
async function handleDetail(row: Api.Project.ProjectTask) {
currentTask.value = await getTaskDetail(row);
detailVisible.value = true;
}
async function handleStatusAction(row: Api.Project.ProjectTask, action: TaskStatusAction | null) {
const detail = await getTaskDetail(row);
const targetAction = action || detail.availableActions[0] || null;
if (!targetAction) {
window.$message?.warning('当前任务暂无可用状态操作');
return;
}
if (shouldRequireTaskProgressBeforeComplete(targetAction, detail)) {
window.$message?.warning('完成任务前请先将进度调整为 100%');
return;
}
currentTask.value = detail;
currentStatusAction.value = targetAction;
statusActionVisible.value = true;
}
async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
if (!props.execution) {
return;
}
if (operateMode.value !== 'create' && !currentTask.value) {
return;
}
const result =
operateMode.value === 'create'
? await fetchCreateProjectTask(props.projectId, props.execution.id, payload)
: await fetchUpdateProjectTask(props.projectId, props.execution.id, {
taskId: currentTask.value!.id,
data: payload
});
if (result.error) {
return;
}
if (operateMode.value === 'create') {
window.$message?.success('任务创建成功');
} else {
window.$message?.success('任务更新成功');
}
operateVisible.value = false;
await getData();
}
async function handleStatusSubmit(reason: string | null) {
if (!props.execution || !currentTask.value || !currentStatusAction.value) {
return;
}
const result = await fetchChangeProjectTaskStatus(props.projectId, props.execution.id, {
taskId: currentTask.value.id,
data: {
actionCode: currentStatusAction.value.actionCode,
reason
}
});
if (result.error) {
return;
}
window.$message?.success('任务状态已更新');
statusActionVisible.value = false;
await Promise.all([getData(), loadTaskStatusBoard()]);
}
async function loadTaskStatusBoard() {
if (!canLoadTasks.value) {
taskStatusBoard.value = null;
return;
}
const { error, data: board } = await fetchGetProjectTaskStatusBoard(
props.projectId,
executionId.value,
createStatusBoardParams()
);
taskStatusBoard.value = error || !board ? null : board;
}
watch(
() => props.execution?.id,
async value => {
resetSearchParams();
if (!value) {
data.value = [];
taskStatusBoard.value = null;
return;
}
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
},
{ immediate: true }
);
</script>
<template>
<section class="task-workspace">
<header class="task-workspace__header">
<h3 class="task-workspace__title">任务</h3>
<div class="task-workspace__actions">
<ElSegmented v-model="viewMode" class="task-view-toggle" size="default" :options="viewModeOptions">
<template #default="{ item }">
<span class="task-view-toggle__item">
<component :is="item.icon" class="task-view-toggle__icon" />
<span>{{ item.label }}</span>
</span>
</template>
</ElSegmented>
<ElButton v-if="canCreate" type="primary" :icon="Plus" :disabled="!execution" @click="handleCreate">
新增
</ElButton>
</div>
</header>
<TaskSearch
:model="searchParams"
:user-options="ownerOptions"
:status-options="taskStatusBoard?.items || []"
:disabled="!execution"
@search="handleSearch"
@reset="handleReset"
/>
<ElEmpty v-if="!execution" class="task-workspace__empty" description="请选择左侧执行项" />
<TaskTableView
v-else-if="viewMode === 'table'"
:data="data"
:loading="loading"
:pagination="mobilePagination"
:can-update="canUpdate"
:can-change-status="canChangeStatus"
@detail="handleDetail"
@edit="handleEdit"
@status-action="handleStatusAction"
/>
<TaskBoardView
v-else
:data="data"
:loading="loading"
:status-board="taskStatusBoard"
:can-update="canUpdate"
:can-change-status="canChangeStatus"
@detail="handleDetail"
@edit="handleEdit"
@status-action="handleStatusAction"
/>
<div v-if="execution && viewMode === 'board' && mobilePagination.total" class="task-workspace__board-pagination">
<ElPagination
background
layout="total, sizes, prev, pager, next"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
<TaskOperateDialog
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="currentTask"
:user-options="ownerOptions"
:task-options="taskOptions"
@submit="handleOperateSubmit"
/>
<TaskDetailDialog v-model:visible="detailVisible" :row-data="currentTask" />
<StatusActionDialog
v-model:visible="statusActionVisible"
:title="statusActionTitle"
:action="currentStatusAction"
@submit="handleStatusSubmit"
/>
</section>
</template>
<style scoped lang="scss">
.task-workspace {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
height: 100%;
padding: 12px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 8px;
background-color: #fff;
}
.task-workspace__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 40px;
}
.task-workspace__title {
margin: 0;
color: rgb(15 23 42 / 94%);
font-size: 16px;
font-weight: 700;
line-height: 1.4;
}
.task-workspace__actions {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.task-view-toggle {
--el-segmented-item-selected-bg-color: var(--el-color-primary);
--el-segmented-item-selected-color: #fff;
}
.task-view-toggle__item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 4px;
font-weight: 500;
}
.task-view-toggle__icon {
font-size: 16px;
}
.task-workspace__board-pagination {
display: flex;
justify-content: flex-end;
}
.task-workspace__empty {
flex: 1;
border: 1px dashed rgb(203 213 225 / 92%);
border-radius: 8px;
background-color: #fff;
}
</style>

View File

@@ -0,0 +1,119 @@
import dayjs from 'dayjs';
import { getStatusTagType } from '@/constants/status-tag';
type ExecutionStatusCode = Api.Project.ProjectExecutionStatusCode;
type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
type ExecutionMemberActionType = Api.Project.ExecutionMemberActionType;
export const executionMemberActionNameMap: Record<ExecutionMemberActionType, string> = {
join: '加入',
inactive: '失效',
owner_transfer_in: '转入负责人',
owner_transfer_out: '转出负责人'
};
export const EXECUTION_STATUS_ORDER = [
'pending',
'active',
'paused',
'completed',
'cancelled'
] as const satisfies readonly ExecutionStatusCode[];
export const TASK_STATUS_ORDER = [
'pending',
'active',
'blocked',
'completed',
'cancelled'
] as const satisfies readonly TaskStatusCode[];
export const executionStatusFallbackNameMap: Record<ExecutionStatusCode, string> = {
pending: '待开始',
active: '进行中',
paused: '已暂停',
completed: '已完成',
cancelled: '已取消'
};
export const taskStatusFallbackNameMap: Record<TaskStatusCode, string> = {
pending: '待开始',
active: '进行中',
blocked: '已阻塞',
completed: '已完成',
cancelled: '已取消'
};
export function getExecutionStatusTagType(statusCode: ExecutionStatusCode | string) {
return getStatusTagType('projectExecution', statusCode);
}
export function getTaskStatusTagType(statusCode: TaskStatusCode | string) {
return getStatusTagType('projectTask', statusCode);
}
export function getExecutionMemberActionTagType(actionType: ExecutionMemberActionType | string) {
return getStatusTagType('executionMember', actionType);
}
export function getExecutionMemberActionName(actionType: ExecutionMemberActionType | string) {
return executionMemberActionNameMap[actionType as ExecutionMemberActionType] || actionType;
}
export function formatDate(value: string | null | undefined) {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '--';
}
export function formatDateTime(value: string | null | undefined) {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '--';
}
export function formatDateRange(startDate: string | null | undefined, endDate: string | null | undefined) {
const startText = formatDate(startDate);
const endText = formatDate(endDate);
if (startText === '--' && endText === '--') {
return '--';
}
return `${startText}${endText}`;
}
export function getExecutionStatusName(execution: Pick<Api.Project.ProjectExecution, 'statusCode' | 'statusName'>) {
return execution.statusName?.trim() || executionStatusFallbackNameMap[execution.statusCode] || execution.statusCode;
}
export function getTaskStatusName(task: Pick<Api.Project.ProjectTask, 'statusCode' | 'statusName'>) {
return task.statusName?.trim() || taskStatusFallbackNameMap[task.statusCode] || task.statusCode;
}
export function getProgressText(progressRate: number | null | undefined) {
if (typeof progressRate !== 'number' || !Number.isFinite(progressRate)) {
return '0%';
}
return `${Math.min(100, Math.max(0, progressRate))}%`;
}
export function isActiveExecutionMember(member: Pick<Api.Project.ExecutionMember, 'joinedAt' | 'removedAt'>) {
if (!member.removedAt) {
return true;
}
if (!member.joinedAt) {
return false;
}
return dayjs(member.joinedAt).isAfter(dayjs(member.removedAt));
}
export function shouldRequireTaskProgressBeforeComplete(
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>,
task: Api.Project.ProjectTask
) {
return action.actionCode === 'complete' && task.progressRate !== 100;
}

View File

@@ -0,0 +1,401 @@
import dayjs from 'dayjs';
import { getProjectStatusLabel } from '../../shared/project-master-data';
export interface ProjectHomepageMetric {
label: string;
value: string;
hint: string;
}
export interface ProjectHomepageFact {
label: string;
value: string;
fullWidth?: boolean;
}
export interface ProjectHomepageBanner {
identity: {
name: string;
code: string;
directionCode: string;
projectType: string;
statusCode: Api.Project.ProjectStatusCode | null;
statusLabel: string;
description: string;
facts: ProjectHomepageFact[];
};
metrics: ProjectHomepageMetric[];
}
export interface ProjectHomepageBannerSource {
project: Api.Project.Project | null;
settings: Api.Project.ProjectSettings | null;
members: readonly Api.Project.ProjectMember[];
latestActivityTime?: string | null;
}
export interface ProjectHomepageTimelineItem {
key: string;
tag: '对象' | '状态' | '团队' | '计划';
title: string;
content: string;
time: string;
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
}
export interface ProjectScheduleOverview {
metrics: ProjectHomepageMetric[];
dates: Array<{
label: string;
value: string;
}>;
}
export interface ProjectTeamOverview {
metrics: ProjectHomepageMetric[];
roles: Array<{
label: string;
value: string;
}>;
}
export interface ProjectHomepageExtensionModule {
key: 'milestone' | 'risk' | 'document';
title: string;
description: string;
items: string[];
}
function getTimeValue(value: string | null | undefined) {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.valueOf() : 0;
}
function formatDate(value: string | null | undefined) {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '--';
}
function formatDateTime(value: string | null | undefined) {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
}
function getStatusLabel(status: Api.Project.ProjectStatusCode | null | undefined) {
return status ? getProjectStatusLabel(status) || '--' : '--';
}
function getActiveMembers(members: readonly Api.Project.ProjectMember[]) {
return members.filter(item => item.status === 0);
}
function getManagerLabel(
project: Api.Project.Project | null,
settings: Api.Project.ProjectSettings | null,
members: readonly Api.Project.ProjectMember[]
) {
return (
settings?.baseInfo.managerUserNickname ||
project?.managerUserNickname ||
getActiveMembers(members).find(item => item.managerFlag)?.userNickname ||
'--'
);
}
function getRoleSummary(members: readonly Api.Project.ProjectMember[]) {
const activeMembers = getActiveMembers(members);
if (!activeMembers.length) {
return '--';
}
const roleCounter = new Map<string, number>();
activeMembers.forEach(member => {
const roleName = member.roleName || '未命名角色';
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
});
return Array.from(roleCounter.entries())
.sort((left, right) => {
const leftWeight = left[0].includes('经理') || left[0].includes('负责人') ? 0 : 1;
const rightWeight = right[0].includes('经理') || right[0].includes('负责人') ? 0 : 1;
if (leftWeight !== rightWeight) {
return leftWeight - rightWeight;
}
return left[0].localeCompare(right[0], 'zh-CN');
})
.map(([roleName, count]) => `${roleName} ${count}`)
.join(' / ');
}
function getPlanRangeLabel(project: Api.Project.Project | null) {
const start = formatDate(project?.plannedStartDate);
const end = formatDate(project?.plannedEndDate);
if (start === '--' && end === '--') {
return '--';
}
return `${start}${end}`;
}
function getProgressValue(project: Api.Project.Project | null) {
const progress = project?.progressRate ?? 0;
if (!Number.isFinite(progress)) {
return 0;
}
return Math.min(100, Math.max(0, progress));
}
function resolveLatestTimelineTime(
project: Api.Project.Project | null,
settings: Api.Project.ProjectSettings | null,
members: readonly Api.Project.ProjectMember[]
) {
const timeValues = [
project?.createTime,
project?.updateTime,
settings?.lifecycle.lastStatusReason ? project?.updateTime : null,
project?.actualStartDate,
project?.actualEndDate,
...members.flatMap(member => [member.joinedTime, member.leftTime || null])
];
const latestValue = timeValues.reduce((latest, current) => Math.max(latest, getTimeValue(current)), 0);
return latestValue ? dayjs(latestValue).format('YYYY-MM-DD HH:mm') : '--';
}
// 待重构:拆 helper 以降低复杂度,暂以 disable 注释临时放行
// eslint-disable-next-line complexity
function buildProjectHomepageBannerIdentity(source: ProjectHomepageBannerSource) {
const { project, settings, members } = source;
const baseInfo = settings?.baseInfo;
const statusCode = settings?.lifecycle.statusCode || project?.statusCode || null;
return {
name: project?.projectName || baseInfo?.projectName || '--',
code: project?.projectCode || baseInfo?.projectCode || '--',
directionCode: project?.directionCode || baseInfo?.directionCode || '',
projectType: project?.projectType || baseInfo?.projectType || '',
statusCode,
statusLabel: getStatusLabel(statusCode),
description: project?.projectDesc?.trim() || baseInfo?.projectDesc?.trim() || '',
facts: [
{ label: '所属产品', value: project?.productName || baseInfo?.productName || project?.productId || '--' },
{ label: '项目经理', value: getManagerLabel(project, settings, members) },
{ label: '角色摘要', value: getRoleSummary(members), fullWidth: true }
]
} satisfies ProjectHomepageBanner['identity'];
}
function buildProjectHomepageBannerMetrics(source: ProjectHomepageBannerSource) {
const activeMembers = getActiveMembers(source.members);
const latestTimelineTime =
source.latestActivityTime?.trim() || resolveLatestTimelineTime(source.project, source.settings, source.members);
return [
{
label: '团队人数',
value: String(activeMembers.length),
hint: '当前处于有效状态的项目成员数'
},
{
label: '项目进度',
value: `${getProgressValue(source.project)}%`,
hint: '项目详情中维护的当前进度百分比'
},
{
label: '计划周期',
value: getPlanRangeLabel(source.project),
hint: '项目计划开始和计划结束日期'
},
{
label: '最近动态时间',
value: latestTimelineTime,
hint: '对象、状态、计划或团队最近一次可确认变动时间'
}
] satisfies ProjectHomepageMetric[];
}
export function buildProjectHomepageBanner(source: ProjectHomepageBannerSource): ProjectHomepageBanner {
return {
identity: buildProjectHomepageBannerIdentity(source),
metrics: buildProjectHomepageBannerMetrics(source)
};
}
export function buildProjectHomepageTimeline(
project: Api.Project.Project | null,
settings: Api.Project.ProjectSettings | null,
members: readonly Api.Project.ProjectMember[]
) {
const items: Array<Omit<ProjectHomepageTimelineItem, 'time'> & { time: string | null | undefined }> = [];
if (project?.createTime) {
items.push({
key: `project-create-${project.id}`,
tag: '对象',
title: '创建项目',
content: `项目 ${project.projectName || project.projectCode} 已创建并进入项目管理域。`,
time: project.createTime,
tone: 'sky'
});
}
const statusReason =
settings?.lifecycle.lastStatusReason || settings?.baseInfo.lastStatusReason || project?.lastStatusReason;
if (project?.updateTime && project?.statusCode && statusReason) {
const toneMap: Record<Api.Project.ProjectStatusCode, ProjectHomepageTimelineItem['tone']> = {
pending: 'slate',
active: 'emerald',
paused: 'amber',
completed: 'emerald',
cancelled: 'rose',
archived: 'slate'
};
items.push({
key: `project-status-${project.id}-${project.updateTime}`,
tag: '状态',
title: `状态调整为${getStatusLabel(project.statusCode)}`,
content: statusReason,
time: project.updateTime,
tone: toneMap[project.statusCode]
});
}
if (project?.actualStartDate) {
items.push({
key: `project-actual-start-${project.id}`,
tag: '计划',
title: '实际开始',
content: '项目已记录实际开始日期,进入真实推进阶段。',
time: project.actualStartDate,
tone: 'emerald'
});
}
if (project?.actualEndDate) {
items.push({
key: `project-actual-end-${project.id}`,
tag: '计划',
title: '实际结束',
content: '项目已记录实际结束日期,可结合状态进入完成或归档处理。',
time: project.actualEndDate,
tone: 'slate'
});
}
members.forEach(member => {
if (member.joinedTime) {
items.push({
key: `member-join-${member.id}`,
tag: '团队',
title: '成员加入',
content: `${member.userNickname}${member.roleName || '未命名角色'} 身份加入当前项目。`,
time: member.joinedTime,
tone: member.managerFlag ? 'emerald' : 'sky'
});
}
if (member.status === 1 && member.leftTime) {
items.push({
key: `member-leave-${member.id}`,
tag: '团队',
title: '成员移出',
content: `${member.userNickname} 已退出当前项目团队。`,
time: member.leftTime,
tone: 'rose'
});
}
});
return items
.filter(item => getTimeValue(item.time) > 0)
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
.slice(0, 8)
.map(item => ({
...item,
time: formatDateTime(item.time)
})) satisfies ProjectHomepageTimelineItem[];
}
export function buildProjectScheduleOverview(project: Api.Project.Project | null): ProjectScheduleOverview {
const progressValue = getProgressValue(project);
const hasActualRange = Boolean(project?.actualStartDate || project?.actualEndDate);
return {
metrics: [
{
label: '当前进度',
value: `${progressValue}%`,
hint: '由项目详情维护的进度字段直接呈现'
},
{
label: '日期完整度',
value: hasActualRange ? '已记录实际日期' : '以计划日期为主',
hint: '用于判断当前项目计划与执行日期是否闭环'
}
],
dates: [
{ label: '计划开始', value: formatDate(project?.plannedStartDate) },
{ label: '计划结束', value: formatDate(project?.plannedEndDate) },
{ label: '实际开始', value: formatDate(project?.actualStartDate) },
{ label: '实际结束', value: formatDate(project?.actualEndDate) }
]
};
}
export function buildProjectTeamOverview(members: readonly Api.Project.ProjectMember[]): ProjectTeamOverview {
const activeMembers = getActiveMembers(members);
const inactiveMembers = members.filter(item => item.status === 1);
const manager = activeMembers.find(item => item.managerFlag);
const roleCounter = new Map<string, number>();
activeMembers.forEach(member => {
const roleName = member.roleName || '未命名角色';
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
});
return {
metrics: [
{
label: '有效成员',
value: String(activeMembers.length),
hint: '当前仍在项目团队中的成员数'
},
{
label: '已退出成员',
value: String(inactiveMembers.length),
hint: '历史加入后已移出的项目成员数'
},
{
label: '项目经理',
value: manager?.userNickname || '--',
hint: '当前团队中标记为项目负责人的成员'
},
{
label: '角色类型',
value: String(roleCounter.size),
hint: '有效成员覆盖的角色种类数'
}
],
roles: Array.from(roleCounter.entries()).map(([label, value]) => ({ label, value: String(value) }))
};
}
export function getProjectHomepageExtensionModules(modules: readonly ProjectHomepageExtensionModule[]) {
return [...modules];
}

View File

@@ -0,0 +1,785 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProject, fetchGetProjectMembers, fetchGetProjectSettings } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useCurrentProject } from '../../shared/use-current-project';
import {
buildProjectHomepageBanner,
buildProjectHomepageTimeline,
buildProjectScheduleOverview,
buildProjectTeamOverview,
getProjectHomepageExtensionModules
} from './homepage';
import { projectHomepageExtensionMock } from './mock';
defineOptions({ name: 'ProjectOverview' });
const { currentObjectId, currentProject } = useCurrentProject();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
const pageLoading = ref(false);
const projectDetail = ref<Api.Project.Project | null>(null);
const settings = ref<Api.Project.ProjectSettings | null>(null);
const members = ref<Api.Project.ProjectMember[]>([]);
const latestActivityTime = ref('');
const timelineItems = computed(() => buildProjectHomepageTimeline(projectDetail.value, settings.value, members.value));
const scheduleOverview = computed(() => buildProjectScheduleOverview(projectDetail.value));
const teamOverview = computed(() => buildProjectTeamOverview(members.value));
const homepageBanner = computed(() =>
buildProjectHomepageBanner({
project: projectDetail.value,
settings: settings.value,
members: members.value,
latestActivityTime: latestActivityTime.value
})
);
const extensionModules = computed(() => getProjectHomepageExtensionModules(projectHomepageExtensionMock));
const directionLabel = computed(() => getDirectionLabel(homepageBanner.value.identity.directionCode, '--'));
const projectTypeLabel = computed(() => getProjectTypeLabel(homepageBanner.value.identity.projectType, '--'));
const bannerFacts = computed(() => [
{
label: '项目方向',
value: directionLabel.value,
fullWidth: false
},
{
label: '项目类型',
value: projectTypeLabel.value,
fullWidth: false
},
...homepageBanner.value.identity.facts
]);
const progressValue = computed(() => projectDetail.value?.progressRate ?? 0);
const bannerStatusClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode ? `project-homepage-banner--${statusCode}` : 'project-homepage-banner--default';
});
const bannerStatusWordClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode
? `project-homepage-banner__status-word--${statusCode}`
: 'project-homepage-banner__status-word--default';
});
async function loadOverviewData(objectId: string) {
pageLoading.value = true;
try {
const [projectResult, settingsResult, membersResult] = await Promise.all([
fetchGetProject(objectId),
fetchGetProjectSettings(objectId),
fetchGetProjectMembers(objectId)
]);
projectDetail.value = projectResult.error ? null : projectResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
latestActivityTime.value = timelineItems.value[0]?.time || '';
} finally {
pageLoading.value = false;
}
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
projectDetail.value = null;
settings.value = null;
members.value = [];
latestActivityTime.value = '';
return;
}
await loadOverviewData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="project-homepage">
<section class="project-homepage-banner" :class="bannerStatusClass">
<div class="project-homepage-banner__identity">
<div class="project-homepage-banner__title-group">
<div class="project-homepage-banner__title-main min-w-0">
<div class="project-homepage-banner__title-row">
<h1 class="project-homepage-banner__title">
{{ homepageBanner.identity.name || currentProject?.projectName || '--' }}
</h1>
<span class="project-homepage-banner__status-word" :class="bannerStatusWordClass">
{{ homepageBanner.identity.statusLabel }}
</span>
</div>
<div class="project-homepage-banner__subtitle">
<span class="project-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
<p v-if="homepageBanner.identity.description" class="project-homepage-banner__description">
{{ homepageBanner.identity.description }}
</p>
</div>
</div>
</div>
<div class="project-homepage-banner__facts">
<div
v-for="item in bannerFacts"
:key="item.label"
class="project-homepage-banner__fact"
:class="{ 'project-homepage-banner__fact--full': item.fullWidth }"
>
<span class="project-homepage-banner__fact-label">{{ item.label }}</span>
<strong class="project-homepage-banner__fact-value">{{ item.value }}</strong>
</div>
</div>
</div>
<div class="project-homepage-banner__metrics">
<article v-for="item in homepageBanner.metrics" :key="item.label" class="project-homepage-banner__metric">
<span class="project-homepage-banner__metric-label">{{ item.label }}</span>
<strong class="project-homepage-banner__metric-value">{{ item.value }}</strong>
</article>
</div>
</section>
<section class="project-homepage-main">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目动态时间线</h3>
<p class="project-homepage-panel__desc">
先展示项目创建状态动作实际日期和团队变化后续可替换为专用动态接口
</p>
</div>
</template>
<div v-if="timelineItems.length" class="project-homepage-timeline">
<article v-for="item in timelineItems" :key="item.key" class="project-homepage-timeline__item">
<div class="project-homepage-timeline__rail">
<span class="project-homepage-timeline__dot" :class="`project-homepage-timeline__dot--${item.tone}`" />
<span class="project-homepage-timeline__line" />
</div>
<div class="project-homepage-timeline__content">
<div class="project-homepage-timeline__meta">
<ElTag effect="plain" size="small">{{ item.tag }}</ElTag>
<span class="project-homepage-timeline__time">{{ item.time }}</span>
</div>
<p class="project-homepage-timeline__sentence">
<strong class="project-homepage-timeline__headline">{{ item.title }}</strong>
<span>{{ item.content }}</span>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="当前暂无可展示的项目动态" :image-size="88" />
</ElCard>
<div class="project-homepage-main__aside">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">计划进展概览</h3>
<p class="project-homepage-panel__desc">先看当前进度计划周期和实际执行日期是否已经闭环</p>
</div>
</template>
<div class="project-homepage-schedule">
<div class="project-homepage-schedule__progress">
<strong>{{ progressValue }}%</strong>
<ElProgress
:percentage="progressValue"
:stroke-width="8"
:show-text="false"
:color="progressValue >= 100 ? '#10b981' : progressValue >= 50 ? '#3b82f6' : '#6366f1'"
/>
</div>
<div class="project-homepage-summary-metrics">
<article
v-for="item in scheduleOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div class="project-homepage-schedule__dates">
<div v-for="item in scheduleOverview.dates" :key="item.label" class="project-homepage-schedule__date">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</ElCard>
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目团队概览</h3>
<p class="project-homepage-panel__desc">承接当前成员规模负责人和角色结构和设置页团队维护分开表达</p>
</div>
</template>
<div class="project-homepage-team">
<div class="project-homepage-summary-metrics">
<article
v-for="item in teamOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div v-if="teamOverview.roles.length" class="project-homepage-team__roles">
<div v-for="item in teamOverview.roles" :key="item.label" class="project-homepage-team__role">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="72" />
</div>
</ElCard>
</div>
</section>
<section class="project-homepage-extension">
<ElCard v-for="module in extensionModules" :key="module.key" class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">{{ module.title }}</h3>
<p class="project-homepage-panel__desc">{{ module.description }}</p>
</div>
</template>
<div class="project-homepage-extension__list">
<div v-for="item in module.items" :key="item" class="project-homepage-extension__item">
<span class="project-homepage-extension__dot" />
<span>{{ item }}</span>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.project-homepage {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 16px;
padding: 24px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.project-homepage-banner--default {
border-color: rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.project-homepage-banner--pending,
.project-homepage-banner--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--active,
.project-homepage-banner--completed {
border-color: rgb(167 243 208 / 88%);
background:
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--paused {
border-color: rgb(253 230 138 / 90%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--cancelled {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner__identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner__title-group {
display: flex;
align-items: flex-start;
gap: 12px;
}
.project-homepage-banner__title-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
}
.project-homepage-banner__title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 14px;
}
.project-homepage-banner__code {
margin: 0;
color: rgb(14 116 144 / 92%);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.project-homepage-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 34px;
line-height: 1.15;
letter-spacing: 0;
}
.project-homepage-banner__subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.project-homepage-banner__description {
margin: 0;
min-width: 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.8;
}
.project-homepage-banner__status-word {
flex-shrink: 0;
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
user-select: none;
}
.project-homepage-banner__status-word--default {
color: rgb(148 163 184 / 48%);
}
.project-homepage-banner__status-word--pending,
.project-homepage-banner__status-word--archived {
color: transparent;
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
background-clip: text;
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--active,
.project-homepage-banner__status-word--completed {
color: transparent;
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--paused {
color: transparent;
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--cancelled {
color: transparent;
background: linear-gradient(180deg, rgb(244 63 94 / 94%), rgb(251 113 133 / 68%));
background-clip: text;
text-shadow: 0 10px 24px rgb(244 63 94 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__fact {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.project-homepage-banner__fact--full {
grid-column: 1 / -1;
align-items: flex-start;
}
.project-homepage-banner__fact-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
white-space: nowrap;
}
.project-homepage-banner__fact-value {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
text-align: right;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: 72%;
text-align: left;
}
.project-homepage-banner__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__metric {
display: flex;
min-height: 112px;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 18px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(241 245 249 / 98%));
}
.project-homepage-banner__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-banner__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.1;
letter-spacing: 0;
word-break: break-word;
}
.project-homepage-main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-main__aside {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-panel {
overflow: hidden;
}
.project-homepage-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.project-homepage-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.project-homepage-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-timeline__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.project-homepage-timeline__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.project-homepage-timeline__dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.project-homepage-timeline__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.project-homepage-timeline__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.project-homepage-timeline__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.project-homepage-timeline__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.project-homepage-timeline__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.project-homepage-timeline__line {
flex: 1;
width: 2px;
min-height: 30px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.project-homepage-timeline__item:last-child .project-homepage-timeline__line {
opacity: 0;
}
.project-homepage-timeline__content {
padding: 12px 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.project-homepage-timeline__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.project-homepage-timeline__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.project-homepage-timeline__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.65;
}
.project-homepage-timeline__headline {
margin-right: 6px;
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.project-homepage-schedule,
.project-homepage-team {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-schedule__progress {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-schedule__progress strong {
color: rgb(15 23 42 / 98%);
font-size: 36px;
line-height: 1.1;
}
.project-homepage-summary-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-summary-metrics__item {
display: flex;
min-height: 100px;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-summary-metrics__label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-summary-metrics__value {
color: rgb(15 23 42 / 98%);
font-size: 22px;
line-height: 1.2;
word-break: break-word;
}
.project-homepage-schedule__dates,
.project-homepage-team__roles {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-schedule__date,
.project-homepage-team__role {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 14px;
background-color: rgb(255 255 255 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
}
.project-homepage-schedule__date strong,
.project-homepage-team__role strong {
color: rgb(15 23 42 / 98%);
font-size: 18px;
}
.project-homepage-extension {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-extension__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-extension__item {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
line-height: 1.7;
}
.project-homepage-extension__dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: 999px;
background-color: rgb(14 116 144 / 88%);
}
@media (width <= 1280px) {
.project-homepage-banner,
.project-homepage-main,
.project-homepage-extension {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.project-homepage-banner {
padding: 18px;
}
.project-homepage-banner__title-row {
flex-wrap: wrap;
}
.project-homepage-banner__title {
font-size: 28px;
}
.project-homepage-banner__status-word {
font-size: 22px;
}
.project-homepage-banner__facts,
.project-homepage-banner__metrics,
.project-homepage-summary-metrics {
grid-template-columns: 1fr;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: none;
}
}
</style>

View File

@@ -0,0 +1,22 @@
import type { ProjectHomepageExtensionModule } from './homepage';
export const projectHomepageExtensionMock = [
{
key: 'milestone',
title: '里程碑推进',
description: '承接项目阶段目标和关键验收点,后续接入真实里程碑接口后替换当前模块数据。',
items: ['项目概览页改版验收', '项目设置与成员维护闭环', '需求执行看板接口接入']
},
{
key: 'risk',
title: '风险点管理',
description: '预留项目级风险摘要,避免把阻塞、延期和资源风险混在动态时间线里表达。',
items: ['项目动态暂由详情和成员记录拼装', '进度字段依赖项目详情手工维护', '里程碑和风险暂未接入专用聚合接口']
},
{
key: 'document',
title: '项目资料',
description: '用于承接项目说明、交付资料、评审纪要和归档材料,当前先保留正式结构位。',
items: ['项目说明与目标范围', '交付物与评审记录', '项目归档材料清单']
}
] satisfies ProjectHomepageExtensionModule[];

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
defineOptions({ name: 'ProjectRequirement' });
</script>
<template>
<div class="p-16px">
<ElEmpty description="需求池功能开发中..." />
</div>
</template>

View File

@@ -0,0 +1,506 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { objectContextDomainConfigs } from '@/constants/object-context';
import {
fetchChangeProjectStatus,
fetchCreateProjectMember,
fetchDeleteProject,
fetchGetProjectMembers,
fetchGetProjectSettings,
fetchGetRoleSimpleList,
fetchGetUserSimpleList,
fetchInactiveProjectMember,
fetchUpdateProjectMember,
fetchUpdateProjectSettingBaseInfo
} from '@/service/api';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { useCurrentProject } from '../../shared/use-current-project';
import BaseInfoDialog from './modules/base-info-dialog.vue';
import MemberOperateDialog from './modules/member-operate-dialog.vue';
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
import ProjectDeleteDialog from './modules/project-delete-dialog.vue';
import SettingAnchorNav from './modules/setting-anchor-nav.vue';
import SettingBaseInfoCard from './modules/setting-base-info-card.vue';
import SettingDangerZone from './modules/setting-danger-zone.vue';
import SettingLifecyclePanel from './modules/setting-lifecycle-panel.vue';
import SettingTeamPanel from './modules/setting-team-panel.vue';
import StatusActionDialog from './modules/status-action-dialog.vue';
import {
type ProjectSettingSectionKey,
canManageProjectTeam,
getProjectSettingSectionKeys,
resolveVisibleProjectSettingSectionKey,
resolveVisibleProjectSettingSections
} from './shared';
defineOptions({ name: 'ProjectSetting' });
const objectContextStore = useObjectContextStore();
const themeStore = useThemeStore();
const { routerPush } = useRouterPush();
const { currentObjectId, currentProject } = useCurrentProject();
const isCompactLayout = useMediaQuery('(max-width: 1280px)');
const projectDomainConfig = objectContextDomainConfigs.find(config => config.domainKey === 'project') || null;
const allAnchorItems = [
{ key: 'base-info', label: '基础信息' },
{ key: 'team', label: '团队管理' },
{ key: 'lifecycle', label: '生命周期管理' },
{ key: 'danger', label: '危险操作' }
] as const;
const anchorLabelMap = new Map(allAnchorItems.map(item => [item.key, item.label]));
const sectionIdMap: Record<ProjectSettingSectionKey, string> = {
'base-info': 'project-setting-base-info',
team: 'project-setting-team',
lifecycle: 'project-setting-lifecycle',
danger: 'project-setting-danger'
};
const activeAnchorKey = ref<ProjectSettingSectionKey>('base-info');
const pageLoading = ref(false);
const memberLoading = ref(false);
const baseInfoVisible = ref(false);
const memberOperateVisible = ref(false);
const memberRemoveVisible = ref(false);
const statusActionVisible = ref(false);
const deleteVisible = ref(false);
const memberOperateMode = ref<'create' | 'edit'>('create');
const selectedMember = ref<Api.Project.ProjectMember | null>(null);
const selectedAction = ref<Api.Project.ProjectLifecycleAction | null>(null);
const settings = ref<Api.Project.ProjectSettings | null>(null);
const members = ref<Api.Project.ProjectMember[]>([]);
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const userOptions = ref<Api.SystemManage.UserSimple[]>([]);
const currentManager = computed(() => members.value.find(item => item.managerFlag && item.status === 0) || null);
const baseInfo = computed(() => settings.value?.baseInfo || null);
const lifecycle = computed(() => settings.value?.lifecycle || null);
const canUpdateProject = computed(() => objectContextStore.buttonCodes.includes('project:project:update'));
const canManageTeam = computed(() => canManageProjectTeam({ buttonCodes: objectContextStore.buttonCodes }));
const canChangeProjectStatus = computed(() => objectContextStore.buttonCodes.includes('project:project:status'));
const canDeleteProject = computed(() => objectContextStore.buttonCodes.includes('project:project:delete'));
const visibleSectionKeys = computed(() =>
resolveVisibleProjectSettingSections(getProjectSettingSectionKeys(), objectContextStore.buttonCodes)
);
const anchorItems = computed(() =>
visibleSectionKeys.value.map(key => ({
key,
label: anchorLabelMap.get(key) || key
}))
);
const layoutScrollTarget = `#${LAYOUT_SCROLL_EL_ID}`;
const anchorAffixOffset = computed(() => {
const fixedTopInset = themeStore.fixedHeaderAndTab
? themeStore.header.height + (themeStore.tabVisible ? themeStore.tab.height : 0)
: 0;
return fixedTopInset + 16;
});
const anchorShellInlineStyle = computed(() => ({
maxHeight: isCompactLayout.value ? '' : `calc(100vh - ${anchorAffixOffset.value + 16}px)`
}));
const showLifecycleSection = computed(() => visibleSectionKeys.value.includes('lifecycle'));
const showDangerSection = computed(() => visibleSectionKeys.value.includes('danger'));
async function refreshContextSummary() {
if (!projectDomainConfig || !currentObjectId.value) {
return;
}
await objectContextStore.enterContext(projectDomainConfig, currentObjectId.value);
}
async function loadSettings() {
if (!currentObjectId.value) {
settings.value = null;
return;
}
const { error, data } = await fetchGetProjectSettings(currentObjectId.value);
if (error || !data) {
settings.value = null;
return;
}
settings.value = data;
}
async function loadMembers() {
if (!currentObjectId.value) {
members.value = [];
return;
}
memberLoading.value = true;
const { error, data } = await fetchGetProjectMembers(currentObjectId.value);
memberLoading.value = false;
if (error || !data) {
members.value = [];
return;
}
members.value = data;
}
async function loadRoleOptions() {
const { error, data } = await fetchGetRoleSimpleList({
scopeType: 'object',
objectType: 'project'
});
if (error || !data) {
roleOptions.value = [];
return;
}
roleOptions.value = data;
}
async function loadUserOptions() {
const { error, data } = await fetchGetUserSimpleList();
if (error || !data) {
userOptions.value = [];
return;
}
userOptions.value = data;
}
async function loadPageData() {
if (!currentObjectId.value) {
return;
}
pageLoading.value = true;
await Promise.all([refreshContextSummary(), loadSettings(), loadMembers(), loadRoleOptions(), loadUserOptions()]);
pageLoading.value = false;
}
function scrollToSection(key: string) {
if (!(key in sectionIdMap)) {
return;
}
const resolvedKey = key as ProjectSettingSectionKey;
if (!visibleSectionKeys.value.includes(resolvedKey)) {
return;
}
activeAnchorKey.value = resolvedKey;
const target = document.getElementById(sectionIdMap[resolvedKey]);
target?.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
function openCreateMember() {
memberOperateMode.value = 'create';
selectedMember.value = null;
memberOperateVisible.value = true;
}
function openEditMember(member: Api.Project.ProjectMember) {
memberOperateMode.value = 'edit';
selectedMember.value = member;
memberOperateVisible.value = true;
}
function openRemoveMember(member: Api.Project.ProjectMember) {
selectedMember.value = member;
memberRemoveVisible.value = true;
}
function openLifecycleAction(action: Api.Project.ProjectLifecycleAction) {
selectedAction.value = action;
statusActionVisible.value = true;
}
async function handleSubmitBaseInfo(payload: Api.Project.UpdateProjectSettingBaseInfoParams) {
if (!currentObjectId.value) {
return;
}
const result = await fetchUpdateProjectSettingBaseInfo(currentObjectId.value, payload);
if (result.error) {
return;
}
window.$message?.success('基础信息更新成功');
baseInfoVisible.value = false;
await Promise.all([loadSettings(), refreshContextSummary()]);
}
async function handleSubmitMemberOperate(event: {
mode: 'create' | 'edit';
memberId?: string;
managerChanged: boolean;
payload: Api.Project.CreateProjectMemberParams | Api.Project.UpdateProjectMemberParams;
}) {
if (!currentObjectId.value) {
return;
}
const result =
event.mode === 'create'
? await fetchCreateProjectMember(currentObjectId.value, event.payload as Api.Project.CreateProjectMemberParams)
: await fetchUpdateProjectMember(
currentObjectId.value,
event.memberId || '',
event.payload as Api.Project.UpdateProjectMemberParams
);
if (result.error) {
return;
}
window.$message?.success(event.mode === 'create' ? '成员新增成功' : '成员角色调整成功');
memberOperateVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
if (event.managerChanged) {
await refreshContextSummary();
}
}
async function handleSubmitRemoveMember(payload: Api.Project.InactiveProjectMemberParams) {
if (!currentObjectId.value || !selectedMember.value?.id) {
return;
}
const result = await fetchInactiveProjectMember(currentObjectId.value, selectedMember.value.id, payload);
if (result.error) {
return;
}
window.$message?.success('成员移出成功');
memberRemoveVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
}
async function handleSubmitLifecycleAction(payload: Api.Project.ChangeProjectStatusParams) {
if (!currentObjectId.value || !selectedAction.value) {
return;
}
const result = await fetchChangeProjectStatus({
...payload,
id: currentObjectId.value
});
if (result.error) {
return;
}
window.$message?.success(`${selectedAction.value.actionName}成功`);
statusActionVisible.value = false;
await Promise.all([loadSettings(), refreshContextSummary()]);
}
async function handleSubmitDelete(payload: Api.Project.DeleteProjectParams) {
const result = await fetchDeleteProject(payload);
if (result.error) {
return;
}
window.$message?.success('项目删除成功');
deleteVisible.value = false;
objectContextStore.clearContext();
await routerPush({
path: '/project/list'
});
}
watch(
visibleSectionKeys,
sectionKeys => {
activeAnchorKey.value = resolveVisibleProjectSettingSectionKey(activeAnchorKey.value, sectionKeys);
},
{
immediate: true
}
);
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
settings.value = null;
members.value = [];
return;
}
await loadPageData();
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="project-setting-page">
<div class="project-setting-page__body">
<div class="project-setting-page__aside">
<div v-if="isCompactLayout" class="project-setting-page__aside-shell" :style="anchorShellInlineStyle">
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
</div>
<ElAffix
v-else
class="project-setting-page__aside-affix"
:offset="anchorAffixOffset"
:target="layoutScrollTarget"
teleported
>
<div class="project-setting-page__aside-shell" :style="anchorShellInlineStyle">
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
</div>
</ElAffix>
</div>
<div class="project-setting-page__content">
<section :id="sectionIdMap['base-info']" class="project-setting-page__section">
<SettingBaseInfoCard :base-info="baseInfo" :readonly="!canUpdateProject" @edit="baseInfoVisible = true" />
</section>
<section :id="sectionIdMap.team" class="project-setting-page__section">
<SettingTeamPanel
:members="members"
:role-options="roleOptions"
:loading="memberLoading"
:readonly="!canManageTeam"
@create="openCreateMember"
@edit="openEditMember"
@remove="openRemoveMember"
/>
</section>
<section v-if="showLifecycleSection" :id="sectionIdMap.lifecycle" class="project-setting-page__section">
<SettingLifecyclePanel
:lifecycle="lifecycle"
:readonly="!canChangeProjectStatus"
@action="openLifecycleAction"
/>
</section>
<section v-if="showDangerSection" :id="sectionIdMap.danger" class="project-setting-page__section">
<SettingDangerZone
:project-name="baseInfo?.projectName || currentProject?.projectName || ''"
:disabled="!canDeleteProject"
@delete="deleteVisible = true"
/>
</section>
</div>
</div>
<BaseInfoDialog v-model:visible="baseInfoVisible" :base-info="baseInfo" @submit="handleSubmitBaseInfo" />
<MemberOperateDialog
v-model:visible="memberOperateVisible"
:mode="memberOperateMode"
:member="selectedMember"
:current-manager="currentManager"
:role-options="roleOptions"
:user-options="
userOptions.filter(user => !members.some(member => member.status === 0 && member.userId === user.id))
"
@submit="handleSubmitMemberOperate"
/>
<MemberRemoveDialog
v-model:visible="memberRemoveVisible"
:member="selectedMember"
@submit="handleSubmitRemoveMember"
/>
<StatusActionDialog
v-model:visible="statusActionVisible"
:action="selectedAction"
@submit="handleSubmitLifecycleAction"
/>
<ProjectDeleteDialog
v-model:visible="deleteVisible"
:project-id="currentObjectId"
:project-name="baseInfo?.projectName || currentProject?.projectName || ''"
@submit="handleSubmitDelete"
/>
</div>
</template>
<style scoped>
.project-setting-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-setting-page__body {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.project-setting-page__aside {
min-width: 0;
align-self: stretch;
}
.project-setting-page__aside-affix {
display: block;
width: 100%;
}
.project-setting-page__aside-shell {
min-height: 100%;
padding: 18px 16px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background:
radial-gradient(circle at top left, rgb(15 118 110 / 7%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
box-sizing: border-box;
overflow-y: auto;
scrollbar-gutter: stable;
}
.project-setting-page__content {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-setting-page__section {
scroll-margin-top: 16px;
}
@media (width <= 1280px) {
.project-setting-page__body {
grid-template-columns: 1fr;
}
.project-setting-page__aside-shell {
min-height: auto;
overflow: visible;
}
}
</style>

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { getProjectBaseInfoReadonlyMessage, isProjectBaseInfoEditable } from '../shared';
defineOptions({ name: 'ProjectBaseInfoDialog' });
interface Props {
baseInfo: Api.Project.ProjectSettingBaseInfo | null;
}
interface Emits {
(e: 'submit', payload: Api.Project.UpdateProjectSettingBaseInfoParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<Api.Project.UpdateProjectSettingBaseInfoParams>({
projectName: '',
directionCode: '',
projectType: '',
plannedStartDate: null,
plannedEndDate: null,
projectDesc: null
});
const baseInfoEditable = computed(() => isProjectBaseInfoEditable(props.baseInfo?.statusCode));
const hasAssociatedProduct = computed(() => Boolean(props.baseInfo?.productId));
const directionReadonly = computed(() => baseInfoEditable.value && hasAssociatedProduct.value);
const readonlyMessage = computed(() => getProjectBaseInfoReadonlyMessage(props.baseInfo?.statusCode));
const confirmDisabled = computed(() => !props.baseInfo || !baseInfoEditable.value);
const directionDisplayName = computed(() => {
const directionCode = props.baseInfo?.directionCode;
if (!directionCode) {
return '';
}
return getDirectionLabel(directionCode, directionCode);
});
const projectTypeDisplayName = computed(() => {
const projectType = props.baseInfo?.projectType;
if (!projectType) {
return '';
}
return getProjectTypeLabel(projectType, projectType);
});
const rules = {
projectName: [createRequiredRule('请输入项目名称')],
directionCode: [createRequiredRule('请选择项目方向')],
projectType: [createRequiredRule('请选择项目类型')]
} satisfies Record<string, App.Global.FormRule[]>;
async function handleConfirm() {
if (confirmDisabled.value) {
return;
}
await validate();
emit('submit', {
projectName: model.projectName.trim(),
directionCode: directionReadonly.value ? props.baseInfo?.directionCode || model.directionCode : model.directionCode,
projectType: model.projectType,
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
projectDesc: model.projectDesc?.trim() || null
});
}
watch(
() => visible.value,
async value => {
if (!value || !props.baseInfo) {
return;
}
model.projectName = props.baseInfo.projectName || '';
model.directionCode = props.baseInfo.directionCode || '';
model.projectType = props.baseInfo.projectType || '';
model.plannedStartDate = props.baseInfo.plannedStartDate;
model.plannedEndDate = props.baseInfo.plannedEndDate;
model.projectDesc = props.baseInfo.projectDesc || '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="编辑基础信息"
preset="md"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
<ElAlert v-if="readonlyMessage" :title="readonlyMessage" type="warning" :closable="false" class="mb-16px" />
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<BusinessFormSection title="项目信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="项目编码">
<ElInput :model-value="baseInfo?.projectCode || ''" readonly class="base-info-dialog__readonly-input" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目名称" prop="projectName">
<ElInput
v-if="baseInfoEditable"
v-model="model.projectName"
maxlength="200"
placeholder="请输入项目名称"
/>
<ElInput
v-else
:model-value="model.projectName"
readonly
class="base-info-dialog__readonly-input"
placeholder="未获取到项目名称"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目方向" prop="directionCode">
<DictSelect
v-if="baseInfoEditable && !directionReadonly"
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择项目方向"
/>
<ElInput
v-else
:model-value="directionDisplayName"
readonly
class="base-info-dialog__readonly-input"
placeholder="未获取到项目方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目类型" prop="projectType">
<DictSelect
v-if="baseInfoEditable"
v-model="model.projectType"
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
filterable
placeholder="请选择项目类型"
/>
<ElInput
v-else
:model-value="projectTypeDisplayName"
readonly
class="base-info-dialog__readonly-input"
placeholder="未获取到项目类型"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所属产品">
<ElInput
:model-value="baseInfo?.productName || baseInfo?.productId || '未关联产品'"
readonly
class="base-info-dialog__readonly-input"
placeholder="未关联产品"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem>
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整项目经理,请到项目内的团队管理处处理。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>项目经理</span>
</span>
</template>
<ElInput
:model-value="baseInfo?.managerUserNickname || baseInfo?.managerUserId || ''"
readonly
class="base-info-dialog__readonly-input"
placeholder="未获取到项目经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划开始日期">
<ElDatePicker
v-if="baseInfoEditable"
v-model="model.plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择计划开始日期"
class="base-info-dialog__date-picker"
/>
<ElInput
v-else
:model-value="model.plannedStartDate || ''"
readonly
class="base-info-dialog__readonly-input"
placeholder="未填写计划开始日期"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划结束日期">
<ElDatePicker
v-if="baseInfoEditable"
v-model="model.plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择计划结束日期"
class="base-info-dialog__date-picker"
/>
<ElInput
v-else
:model-value="model.plannedEndDate || ''"
readonly
class="base-info-dialog__readonly-input"
placeholder="未填写计划结束日期"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="项目说明">
<ElInput
v-if="baseInfoEditable"
v-model="model.projectDesc"
type="textarea"
:rows="4"
maxlength="4000"
show-word-limit
placeholder="请输入项目说明"
/>
<ElInput
v-else
:model-value="model.projectDesc || ''"
type="textarea"
:rows="4"
readonly
class="base-info-dialog__readonly-input"
placeholder="未填写项目说明"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.base-info-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
resize: none;
}
:deep(.base-info-dialog__readonly-input .el-input__wrapper:hover),
:deep(.base-info-dialog__readonly-input.is-focus .el-input__wrapper),
:deep(.base-info-dialog__readonly-input .el-textarea__inner:hover),
:deep(.base-info-dialog__readonly-input .el-textarea__inner:focus) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.base-info-dialog__readonly-input .el-input__inner),
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
:deep(.base-info-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,226 @@
<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';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { getPreviousProjectManagerRoleOptions, shouldRequireProjectManagerHandover } from '../shared';
defineOptions({ name: 'ProjectMemberOperateDialog' });
type OperateMode = 'create' | 'edit';
interface Props {
mode: OperateMode;
member: Api.Project.ProjectMember | null;
currentManager: Api.Project.ProjectMember | null;
roleOptions: Api.SystemManage.RoleSimple[];
userOptions: Api.SystemManage.UserSimple[];
}
interface SubmitPayload {
mode: OperateMode;
memberId?: string;
managerChanged: boolean;
payload: Api.Project.CreateProjectMemberParams | Api.Project.UpdateProjectMemberParams;
}
interface Emits {
(e: 'submit', payload: SubmitPayload): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
interface Model {
userId: string;
roleId: string;
remark: string;
reason: string;
previousManagerRoleId: string;
}
const model = reactive<Model>({
userId: '',
roleId: '',
remark: '',
reason: '',
previousManagerRoleId: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [item.id, item.nickname])));
const selectedUserId = computed(() => (props.mode === 'create' ? model.userId : props.member?.userId || ''));
const showManagerHandover = computed(() => {
return (
shouldRequireProjectManagerHandover(model.roleId, props.currentManager) &&
Boolean(selectedUserId.value) &&
selectedUserId.value !== props.currentManager?.userId
);
});
const previousManagerRoleOptions = computed(() =>
getPreviousProjectManagerRoleOptions(props.roleOptions, props.currentManager?.roleId || '')
);
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')],
previousManagerRoleId: showManagerHandover.value ? [createRequiredRule('请选择原项目经理交接后角色')] : []
}) satisfies Record<string, App.Global.FormRule[]>
);
async function handleConfirm() {
await validate();
const sharedPayload = {
roleId: model.roleId,
remark: model.remark.trim() || null,
previousManagerUserId: showManagerHandover.value ? props.currentManager?.userId || null : null,
previousManagerRoleId: showManagerHandover.value ? model.previousManagerRoleId : null
};
if (props.mode === 'create') {
emit('submit', {
mode: 'create',
managerChanged: showManagerHandover.value,
payload: {
userId: model.userId,
roleId: sharedPayload.roleId,
remark: sharedPayload.remark,
previousManagerUserId: sharedPayload.previousManagerUserId,
previousManagerRoleId: sharedPayload.previousManagerRoleId
}
});
return;
}
emit('submit', {
mode: 'edit',
memberId: props.member?.id,
managerChanged: showManagerHandover.value,
payload: {
roleId: sharedPayload.roleId,
reason: model.reason.trim() || null,
remark: sharedPayload.remark,
previousManagerUserId: sharedPayload.previousManagerUserId,
previousManagerRoleId: sharedPayload.previousManagerRoleId
}
});
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.userId = props.mode === 'create' ? '' : props.member?.userId || '';
model.roleId = props.mode === 'create' ? '' : props.member?.roleId || '';
model.remark = props.mode === 'create' ? '' : props.member?.remark || '';
model.reason = '';
model.previousManagerRoleId = '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<BusinessFormSection title="成员信息">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect v-model="model.userId" :options="userOptions" placeholder="请选择成员用户" />
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput
:model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''"
readonly
class="member-operate-dialog__readonly-input"
placeholder="未获取到成员用户"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" filterable placeholder="请选择角色">
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注">
<ElInput
v-model="model.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入备注"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection v-if="mode === 'edit'" title="角色调整说明">
<ElFormItem label="变更原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入变更原因"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection v-if="showManagerHandover" title="项目经理交接">
<ElAlert
:title="`当前项目经理 ${currentManager?.userNickname || currentManager?.userId || ''} 将完成交接,请选择其交接后角色。`"
type="warning"
:closable="false"
class="mb-16px"
/>
<ElFormItem label="原项目经理交接后角色" prop="previousManagerRoleId">
<ElSelect v-model="model.previousManagerRoleId" class="w-full" placeholder="请选择原项目经理交接后角色">
<ElOption v-for="item in previousManagerRoleOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.member-operate-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.member-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.member-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.member-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProjectMemberRemoveDialog' });
interface Props {
member: Api.Project.ProjectMember | null;
}
interface Emits {
(e: 'submit', payload: Api.Project.InactiveProjectMemberParams): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
function handleConfirm() {
emit('submit', {
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="移出成员" preset="sm" @confirm="handleConfirm">
<ElAlert
:title="`确认将 ${member?.userNickname || member?.userId || '--'} 从当前项目团队中移出吗?`"
type="warning"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="移出原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入移出原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProjectDeleteDialog' });
interface Props {
projectId: string;
projectName: string;
}
interface Emits {
(e: 'submit', payload: Api.Project.DeleteProjectParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
confirmName: '',
confirmText: '',
reason: ''
});
const confirmDisabled = computed(() => {
return (
!model.reason.trim() || model.confirmName.trim() !== props.projectName || model.confirmText.trim() !== 'DELETE'
);
});
function handleConfirm() {
emit('submit', {
id: props.projectId,
projectName: model.confirmName.trim(),
confirmText: model.confirmText.trim(),
reason: model.reason.trim()
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.confirmName = '';
model.confirmText = '';
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="删除项目"
preset="sm"
:confirm-disabled="confirmDisabled"
confirm-text="确认删除"
@confirm="handleConfirm"
>
<ElAlert
:title="`请输入当前项目名称 ${projectName || '--'} 和确认口令 DELETE删除后将退出当前对象上下文。`"
type="error"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="删除确认名称">
<ElInput v-model="model.confirmName" placeholder="请输入当前项目名称" />
</ElFormItem>
<ElFormItem label="确认口令">
<ElInput v-model="model.confirmText" placeholder="请输入 DELETE" />
</ElFormItem>
<ElFormItem label="删除原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入删除原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
defineOptions({ name: 'ProjectSettingAnchorNav' });
interface ProjectSettingAnchorItem {
key: string;
label: string;
}
interface Props {
items: readonly ProjectSettingAnchorItem[];
activeKey: string;
}
interface Emits {
(e: 'select', key: string): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
</script>
<template>
<div class="project-setting-anchor-nav">
<div class="project-setting-anchor-nav__title">设置目录</div>
<div class="project-setting-anchor-nav__list">
<button
v-for="item in items"
:key="item.key"
type="button"
class="project-setting-anchor-nav__item"
:class="{ 'is-active': item.key === activeKey }"
@click="emit('select', item.key)"
>
{{ item.label }}
</button>
</div>
</div>
</template>
<style scoped>
.project-setting-anchor-nav {
display: flex;
flex-direction: column;
gap: 14px;
}
.project-setting-anchor-nav__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.project-setting-anchor-nav__list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.project-setting-anchor-nav__item {
display: flex;
align-items: center;
width: 100%;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 14px;
background-color: rgb(248 250 252 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 14px;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.project-setting-anchor-nav__item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
}
.project-setting-anchor-nav__item.is-active {
border-color: rgb(13 148 136 / 42%);
background-color: rgb(240 253 250 / 98%);
color: rgb(15 118 110 / 96%);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed } from 'vue';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import { getProjectStatusLabel, getProjectStatusTagType } from '../../../shared/project-master-data';
import { isProjectBaseInfoEditable } from '../shared';
defineOptions({ name: 'ProjectSettingBaseInfoCard' });
interface Props {
baseInfo: Api.Project.ProjectSettingBaseInfo | null;
readonly?: boolean;
}
interface Emits {
(e: 'edit'): void;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false
});
const emit = defineEmits<Emits>();
const editDisabled = computed(() => {
if (!props.baseInfo) {
return true;
}
return props.readonly || !isProjectBaseInfoEditable(props.baseInfo.statusCode);
});
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
function formatDate(date: string | null | undefined) {
if (!date) {
return '--';
}
return dayjs(date).format('YYYY-MM-DD');
}
function formatActualStartDate(date: string | null | undefined) {
return date ? dayjs(date).format('YYYY-MM-DD') : '未开始';
}
function formatActualEndDate(date: string | null | undefined) {
return date ? dayjs(date).format('YYYY-MM-DD') : '未结束';
}
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div>
<h3 class="text-16px text-[#0f172a] font-700">基础信息</h3>
</div>
<ElButton type="primary" plain :disabled="editDisabled" @click="emit('edit')">编辑基础信息</ElButton>
</div>
</template>
<ElDescriptions v-if="baseInfo" :column="2" border>
<ElDescriptionsItem label="项目编码">{{ baseInfo.projectCode || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="项目名称">{{ baseInfo.projectName || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="项目方向">
{{ getDirectionLabel(baseInfo.directionCode, '--') }}
</ElDescriptionsItem>
<ElDescriptionsItem label="项目类型">
{{ getProjectTypeLabel(baseInfo.projectType, '--') }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所属产品">{{ baseInfo.productName || baseInfo.productId || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="项目经理">
{{ baseInfo.managerUserNickname || baseInfo.managerUserId || '--' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="计划开始日期">{{ formatDate(baseInfo.plannedStartDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="计划结束日期">{{ formatDate(baseInfo.plannedEndDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="实际开始日期">
{{ formatActualStartDate(baseInfo.actualStartDate) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="实际结束日期">{{ formatActualEndDate(baseInfo.actualEndDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="当前状态">
<ElTag :type="getProjectStatusTagType(baseInfo.statusCode)">
{{ getProjectStatusLabel(baseInfo.statusCode) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="项目说明" :span="2">
<div class="project-setting-base-info-card__description">{{ baseInfo.projectDesc || '--' }}</div>
</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到基础信息" />
</ElCard>
</template>
<style scoped>
.project-setting-base-info-card__description {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
defineOptions({ name: 'ProjectSettingDangerZone' });
interface Props {
projectName: string;
disabled?: boolean;
}
interface Emits {
(e: 'delete'): void;
}
withDefaults(defineProps<Props>(), {
disabled: false
});
const emit = defineEmits<Emits>();
</script>
<template>
<ElCard class="project-setting-danger-zone card-wrapper">
<div class="project-setting-danger-zone__content">
<div class="min-w-0 flex-1">
<h3 class="text-16px text-[#7f1d1d] font-700">危险操作</h3>
<p class="mt-8px text-14px text-[#991b1b] leading-24px">
删除后将退出当前项目对象上下文并返回项目入口页删除时必须输入当前项目名称
<strong>{{ projectName || '--' }}</strong>
进行二次确认
</p>
</div>
<ElButton type="danger" plain :disabled="disabled" @click="emit('delete')">删除项目</ElButton>
</div>
</ElCard>
</template>
<style scoped>
.project-setting-danger-zone {
border: 1px solid rgb(254 202 202 / 96%);
background:
radial-gradient(circle at top right, rgb(254 226 226 / 96%), transparent 35%),
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(254 242 242 / 96%));
}
.project-setting-danger-zone__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
</style>

View File

@@ -0,0 +1,407 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getProjectStatusLabel } from '../../../shared/project-master-data';
import { getProjectLifecycleActionCardMeta, getProjectLifecycleStatusSummary } from '../shared';
defineOptions({ name: 'ProjectSettingLifecyclePanel' });
interface Props {
lifecycle: Api.Project.ProjectLifecycleInfo | null;
readonly?: boolean;
}
interface Emits {
(e: 'action', action: Api.Project.ProjectLifecycleAction): void;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false
});
const emit = defineEmits<Emits>();
const statusSummary = computed(() => {
if (!props.lifecycle) {
return null;
}
return getProjectLifecycleStatusSummary(props.lifecycle.statusCode);
});
const actionCards = computed(() =>
(props.lifecycle?.availableActions || []).map(action => ({
...action,
...getProjectLifecycleActionCardMeta(action.actionCode)
}))
);
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">生命周期管理</h3>
</div>
</template>
<template v-if="lifecycle">
<div class="setting-lifecycle-panel__layout">
<section
class="setting-lifecycle-panel__hero"
:class="[`setting-lifecycle-panel__hero--${statusSummary?.tone || 'slate'}`]"
>
<div class="setting-lifecycle-panel__hero-top">
<div class="setting-lifecycle-panel__hero-main">
<div class="setting-lifecycle-panel__hero-status-row">
<span class="setting-lifecycle-panel__hero-status-label">当前状态</span>
<span class="setting-lifecycle-panel__hero-status-chip">
{{ getProjectStatusLabel(lifecycle.statusCode) }}
</span>
</div>
<h4 class="setting-lifecycle-panel__hero-title">{{ statusSummary?.caption }}</h4>
</div>
</div>
<p class="setting-lifecycle-panel__hero-desc">
{{ statusSummary?.description }}
</p>
<div class="setting-lifecycle-panel__reason-card">
<span class="setting-lifecycle-panel__reason-label">最近状态原因</span>
<strong class="setting-lifecycle-panel__reason-value">
{{ lifecycle.lastStatusReason || '当前没有记录状态原因。' }}
</strong>
</div>
</section>
<section class="setting-lifecycle-panel__action-panel">
<div class="setting-lifecycle-panel__action-head">
<h4 class="setting-lifecycle-panel__action-title">可执行动作</h4>
</div>
<div v-if="actionCards.length > 0" class="setting-lifecycle-panel__action-grid">
<button
v-for="action in actionCards"
:key="action.actionCode"
type="button"
class="setting-lifecycle-panel__action-card"
:class="[`setting-lifecycle-panel__action-card--${action.tone}`]"
:disabled="props.readonly"
@click="emit('action', action)"
>
<div class="setting-lifecycle-panel__action-card-top">
<span class="setting-lifecycle-panel__action-dot" aria-hidden="true"></span>
<strong class="setting-lifecycle-panel__action-name">{{ action.actionName }}</strong>
</div>
<p class="setting-lifecycle-panel__action-desc">{{ action.description }}</p>
</button>
</div>
<div v-else class="setting-lifecycle-panel__empty-tip">当前状态下暂无可执行生命周期动作</div>
</section>
</div>
</template>
<ElEmpty v-else description="未获取到生命周期信息" />
</ElCard>
</template>
<style scoped>
.setting-lifecycle-panel__layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 16px;
align-items: start;
}
.setting-lifecycle-panel__hero,
.setting-lifecycle-panel__action-panel {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100%;
padding: 18px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background-color: rgb(248 250 252 / 96%);
}
.setting-lifecycle-panel__hero {
overflow: hidden;
background:
radial-gradient(circle at top left, rgb(15 118 110 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__hero--emerald {
border-color: rgb(16 185 129 / 22%);
}
.setting-lifecycle-panel__hero--amber {
border-color: rgb(245 158 11 / 22%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 251 235 / 97%));
}
.setting-lifecycle-panel__hero--slate {
border-color: rgb(100 116 139 / 22%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__hero--rose {
border-color: rgb(244 63 94 / 22%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 241 242 / 97%));
}
.setting-lifecycle-panel__hero-top,
.setting-lifecycle-panel__action-head {
display: flex;
align-items: center;
gap: 16px;
}
.setting-lifecycle-panel__hero-main {
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-lifecycle-panel__hero-status-row {
display: flex;
align-items: center;
gap: 10px;
}
.setting-lifecycle-panel__hero-status-label {
color: rgb(71 85 105 / 94%);
font-size: 12px;
font-weight: 600;
}
.setting-lifecycle-panel__hero-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
}
.setting-lifecycle-panel__hero-title {
color: rgb(15 23 42 / 96%);
font-size: 22px;
font-weight: 700;
line-height: 1.25;
}
.setting-lifecycle-panel__action-title {
color: rgb(15 23 42 / 96%);
font-size: 18px;
font-weight: 700;
line-height: 1.25;
}
.setting-lifecycle-panel__hero-desc {
max-width: 560px;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.7;
}
.setting-lifecycle-panel__reason-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 16px;
background-color: rgb(255 255 255 / 82%);
}
.setting-lifecycle-panel__reason-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.setting-lifecycle-panel__reason-value {
color: rgb(15 23 42 / 94%);
font-size: 15px;
line-height: 1.7;
}
.setting-lifecycle-panel__action-panel {
background:
radial-gradient(circle at top right, rgb(59 130 246 / 7%), transparent 32%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__action-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
}
.setting-lifecycle-panel__action-card {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 18px;
background-color: rgb(255 255 255 / 96%);
text-align: left;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.setting-lifecycle-panel__action-card:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgb(15 23 42 / 6%);
}
.setting-lifecycle-panel__action-card:disabled {
opacity: 0.58;
cursor: not-allowed;
}
.setting-lifecycle-panel__action-card:disabled:hover {
transform: none;
box-shadow: none;
}
.setting-lifecycle-panel__action-card-top {
display: flex;
align-items: center;
gap: 10px;
}
.setting-lifecycle-panel__action-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background-color: currentcolor;
flex: 0 0 auto;
}
.setting-lifecycle-panel__action-name {
color: rgb(15 23 42 / 96%);
font-size: 16px;
font-weight: 700;
}
.setting-lifecycle-panel__action-desc {
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.6;
}
.setting-lifecycle-panel__empty-tip {
padding: 18px 16px;
border: 1px dashed rgb(203 213 225 / 92%);
border-radius: 16px;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.setting-lifecycle-panel__hero--emerald .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(16 185 129 / 24%);
background-color: rgb(236 253 245 / 90%);
color: rgb(4 120 87 / 96%);
}
.setting-lifecycle-panel__hero--amber .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(245 158 11 / 24%);
background-color: rgb(255 247 237 / 94%);
color: rgb(180 83 9 / 96%);
}
.setting-lifecycle-panel__hero--slate .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(148 163 184 / 28%);
background-color: rgb(241 245 249 / 94%);
color: rgb(71 85 105 / 96%);
}
.setting-lifecycle-panel__hero--rose .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(244 63 94 / 24%);
background-color: rgb(255 241 242 / 94%);
color: rgb(190 24 93 / 96%);
}
.setting-lifecycle-panel__action-card--emerald {
border-color: rgb(16 185 129 / 22%);
background: linear-gradient(90deg, rgb(236 253 245 / 90%), rgb(255 255 255 / 96%) 26%);
color: rgb(4 120 87 / 96%);
}
.setting-lifecycle-panel__action-card--amber {
border-color: rgb(245 158 11 / 22%);
background: linear-gradient(90deg, rgb(255 247 237 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(180 83 9 / 96%);
}
.setting-lifecycle-panel__action-card--slate {
border-color: rgb(148 163 184 / 26%);
background: linear-gradient(90deg, rgb(241 245 249 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(71 85 105 / 96%);
}
.setting-lifecycle-panel__action-card--rose {
border-color: rgb(244 63 94 / 22%);
background: linear-gradient(90deg, rgb(255 241 242 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(190 24 93 / 96%);
}
.setting-lifecycle-panel__action-card--emerald:hover {
box-shadow: 0 10px 22px rgb(16 185 129 / 12%);
}
.setting-lifecycle-panel__action-card--amber:hover {
box-shadow: 0 10px 22px rgb(245 158 11 / 12%);
}
.setting-lifecycle-panel__action-card--slate:hover {
box-shadow: 0 10px 22px rgb(100 116 139 / 10%);
}
.setting-lifecycle-panel__action-card--rose:hover {
box-shadow: 0 10px 22px rgb(244 63 94 / 12%);
}
.setting-lifecycle-panel__action-card--emerald .setting-lifecycle-panel__action-name {
color: rgb(6 95 70 / 96%);
}
.setting-lifecycle-panel__action-card--amber .setting-lifecycle-panel__action-name {
color: rgb(146 64 14 / 96%);
}
.setting-lifecycle-panel__action-card--slate .setting-lifecycle-panel__action-name {
color: rgb(51 65 85 / 96%);
}
.setting-lifecycle-panel__action-card--rose .setting-lifecycle-panel__action-name {
color: rgb(159 18 57 / 96%);
}
@media (width <= 1280px) {
.setting-lifecycle-panel__layout {
grid-template-columns: 1fr;
}
}
@media (width <= 640px) {
.setting-lifecycle-panel__hero-top,
.setting-lifecycle-panel__action-head {
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { filterProjectMembers, formatProjectMemberDate, getProjectTeamTableHeight } from '../shared';
defineOptions({ name: 'ProjectSettingTeamPanel' });
interface Props {
members: Api.Project.ProjectMember[];
roleOptions?: Api.SystemManage.RoleSimple[];
loading?: boolean;
readonly?: boolean;
}
interface Emits {
(e: 'create'): void;
(e: 'edit', member: Api.Project.ProjectMember): void;
(e: 'remove', member: Api.Project.ProjectMember): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
readonly: false,
roleOptions: () => []
});
const emit = defineEmits<Emits>();
const searchKeyword = ref('');
const selectedRoleId = ref('');
const teamTableHeight = getProjectTeamTableHeight(5);
const roleFilterOptions = computed(() => {
const roleMap = new Map<string, string>();
props.roleOptions.forEach(role => {
if (!roleMap.has(role.id)) {
roleMap.set(role.id, role.name);
}
});
return [...roleMap.entries()].map(([value, label]) => ({
value,
label
}));
});
const filteredMembers = computed(() =>
filterProjectMembers(props.members, {
keyword: searchKeyword.value,
roleId: selectedRoleId.value
})
);
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
watch(roleFilterOptions, options => {
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
selectedRoleId.value = '';
}
});
function getMemberStatusLabel(status: Api.Project.ProjectMemberStatus) {
return status === 0 ? '有效' : '失效';
}
function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
return status === 0 ? 'success' : 'info';
}
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div class="setting-team-panel__header">
<div>
<h3 class="text-16px text-[#0f172a] font-700">团队管理</h3>
</div>
<div class="setting-team-panel__toolbar">
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
<ElOption
v-for="option in roleFilterOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
<ElButton type="primary" plain :disabled="props.readonly" @click="emit('create')">新增成员</ElButton>
</div>
</div>
</template>
<ElTable
v-loading="props.loading"
:data="filteredMembers"
:height="teamTableHeight"
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
border
row-key="id"
>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
<ElTableColumn label="成员状态" width="110" align="center">
<template #default="{ row }">
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="joinedTime" label="加入时间" min-width="132" align="center">
<template #default="{ row }">
{{ formatProjectMemberDate(row.joinedTime) }}
</template>
</ElTableColumn>
<ElTableColumn prop="leftTime" label="退出时间" min-width="170">
<template #default="{ row }">
{{ formatProjectMemberDate(row.leftTime) }}
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<div class="setting-team-panel__actions">
<ElButton
link
type="primary"
:disabled="props.readonly || row.status !== 0 || row.managerFlag"
@click="emit('edit', row)"
>
编辑
</ElButton>
<ElButton
link
type="danger"
:disabled="props.readonly || row.status !== 0 || row.managerFlag"
@click="emit('remove', row)"
>
移出成员
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
</template>
<style scoped>
.setting-team-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.setting-team-panel__toolbar {
display: inline-flex;
align-items: center;
gap: 12px;
}
.setting-team-panel__search {
width: 220px;
}
.setting-team-panel__role-filter {
width: 180px;
}
.setting-team-panel__actions {
display: inline-flex;
align-items: center;
gap: 12px;
}
@media (width <= 768px) {
.setting-team-panel__header {
align-items: flex-start;
flex-direction: column;
}
.setting-team-panel__toolbar {
width: 100%;
}
.setting-team-panel__search {
width: 100%;
}
.setting-team-panel__role-filter {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProjectStatusActionDialog' });
interface Props {
action: Api.Project.ProjectLifecycleAction | null;
}
interface Emits {
(e: 'submit', payload: Api.Project.ChangeProjectStatusParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
const confirmDisabled = computed(() => Boolean(props.action?.needReason && !model.reason.trim()));
function handleConfirm() {
if (!props.action) {
return;
}
emit('submit', {
id: '',
actionCode: props.action.actionCode,
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="action ? action.actionName : '生命周期动作'"
preset="sm"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
<ElForm label-position="top">
<ElFormItem :label="action?.needReason ? '动作原因(必填)' : '动作原因(选填)'">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入动作原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,214 @@
import dayjs from 'dayjs';
export interface ProjectManagerMemberLike {
roleId: string;
}
interface ProjectTeamManageContext {
buttonCodes: readonly string[];
}
interface ProjectLifecycleStatusSummary {
tone: 'emerald' | 'amber' | 'slate' | 'rose';
caption: string;
description: string;
}
interface ProjectLifecycleActionCardMeta {
tone: 'emerald' | 'amber' | 'slate' | 'rose';
description: string;
}
export const projectSettingSectionKeys = ['base-info', 'team', 'lifecycle', 'danger'] as const;
export type ProjectSettingSectionKey = (typeof projectSettingSectionKeys)[number];
const projectBaseInfoReadonlyMessageMap: Partial<Record<Api.Project.ProjectStatusCode, string>> = {
paused: '当前项目已暂停,基础信息仅支持查看,不可编辑。',
completed: '当前项目已完成,基础信息仅支持查看,不可编辑。',
cancelled: '当前项目已取消,基础信息仅支持查看,不可编辑。',
archived: '当前项目已归档,基础信息仅支持查看,不可编辑。'
};
const projectLifecycleStatusSummaryMap: Record<Api.Project.ProjectStatusCode, ProjectLifecycleStatusSummary> = {
pending: {
tone: 'slate',
caption: '项目待开始',
description: '项目已创建,尚未进入真实推进阶段。'
},
active: {
tone: 'emerald',
caption: '项目推进中',
description: '当前可以暂停、完成或取消项目。'
},
paused: {
tone: 'amber',
caption: '项目已暂停',
description: '条件恢复后可重新推进,也可取消项目。'
},
completed: {
tone: 'emerald',
caption: '项目已完成',
description: '项目已完成,可按业务需要重新开启或归档。'
},
cancelled: {
tone: 'rose',
caption: '项目已取消',
description: '项目已取消,当前暂无可执行生命周期动作。'
},
archived: {
tone: 'slate',
caption: '项目已归档',
description: '项目已收口归档,当前暂无可执行生命周期动作。'
}
};
const projectLifecycleActionCardMetaMap: Record<
Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>,
ProjectLifecycleActionCardMeta
> = {
pause: {
tone: 'amber',
description: '暂停当前项目,暂停期间不允许普通编辑和成员维护。'
},
resume: {
tone: 'emerald',
description: '恢复项目推进,重新进入进行中状态。'
},
complete: {
tone: 'emerald',
description: '确认项目完成,结束当前项目推进。'
},
cancel: {
tone: 'rose',
description: '取消当前项目,需要填写取消原因。'
},
reopen: {
tone: 'emerald',
description: '重新开启项目,恢复到进行中状态。'
},
archive: {
tone: 'slate',
description: '归档已完成项目,保留历史记录。'
}
};
const projectTeamTableHeaderHeight = 40;
const projectTeamTableRowHeight = 40;
export function shouldRequireProjectManagerHandover(
targetRoleId: string,
currentManager: ProjectManagerMemberLike | null | undefined
) {
if (!currentManager?.roleId) {
return false;
}
return targetRoleId === currentManager.roleId;
}
export function getPreviousProjectManagerRoleOptions(
roleOptions: Api.SystemManage.RoleSimple[],
managerRoleId: string
) {
return roleOptions.filter(role => role.id !== managerRoleId);
}
export function getProjectSettingSectionKeys() {
return [...projectSettingSectionKeys];
}
export function isProjectBaseInfoEditable(status: Api.Project.ProjectStatusCode | null | undefined) {
return status === 'pending' || status === 'active';
}
export function resolveVisibleProjectSettingSections(
sectionKeys: readonly ProjectSettingSectionKey[],
_buttonCodes: readonly string[]
) {
return [...sectionKeys];
}
export function resolveVisibleProjectSettingSectionKey(
currentKey: ProjectSettingSectionKey | string | null | undefined,
visibleSectionKeys: readonly ProjectSettingSectionKey[]
) {
if (!visibleSectionKeys.length) {
return 'base-info' satisfies ProjectSettingSectionKey;
}
if (currentKey && visibleSectionKeys.includes(currentKey as ProjectSettingSectionKey)) {
return currentKey as ProjectSettingSectionKey;
}
return visibleSectionKeys[0];
}
export function getProjectBaseInfoReadonlyMessage(status: Api.Project.ProjectStatusCode | null | undefined) {
if (!status || isProjectBaseInfoEditable(status)) {
return '';
}
return projectBaseInfoReadonlyMessageMap[status] || '当前项目状态不允许编辑基础信息。';
}
export function getProjectLifecycleStatusSummary(status: Api.Project.ProjectStatusCode) {
return projectLifecycleStatusSummaryMap[status];
}
export function getProjectLifecycleActionCardMeta(
actionCode: Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>
) {
return projectLifecycleActionCardMetaMap[actionCode];
}
export function formatProjectMemberDate(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') {
return '--';
}
const normalizedValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
const parsedDate = dayjs(normalizedValue);
if (!parsedDate.isValid()) {
return String(value);
}
return parsedDate.format('YYYY-MM-DD');
}
export function filterProjectMembers(
members: readonly Api.Project.ProjectMember[],
filters: {
keyword?: string | null | undefined;
roleId?: string | null | undefined;
}
) {
const normalizedKeyword = String(filters.keyword || '')
.trim()
.toLocaleLowerCase();
const normalizedRoleId = String(filters.roleId || '').trim();
if (!normalizedKeyword && !normalizedRoleId) {
return [...members];
}
return members.filter(member => {
const matchesKeyword = !normalizedKeyword || member.userNickname.toLocaleLowerCase().includes(normalizedKeyword);
const matchesRole = !normalizedRoleId || member.roleId === normalizedRoleId;
return matchesKeyword && matchesRole;
});
}
export function getProjectTeamTableHeight(visibleRows: number) {
const normalizedRows = Math.max(0, visibleRows);
return projectTeamTableHeaderHeight + normalizedRows * projectTeamTableRowHeight;
}
export function canManageProjectTeam(context: ProjectTeamManageContext) {
return (
context.buttonCodes.includes('project:project:member') || context.buttonCodes.includes('project:project:update')
);
}

View File

@@ -0,0 +1,115 @@
<script setup lang="tsx">
import { computed } from 'vue';
import { getProjectStatusLabel, getProjectStatusTagType } from './project-master-data';
import type { CurrentProjectSummary } from './project-context-shared';
defineOptions({ name: 'ProjectContextBanner' });
interface Props {
project: CurrentProjectSummary | null;
caption?: string;
}
const props = withDefaults(defineProps<Props>(), {
caption: ''
});
const projectStatusCode = computed(() => props.project?.statusCode as Api.Project.ProjectStatusCode | undefined);
const summaryItems = computed(() => {
if (!props.project) {
return [];
}
return [
{ label: '项目 ID', value: props.project.id || '--' },
{ label: '项目编码', value: props.project.projectCode || '--' },
{ label: '项目类型', value: props.project.projectType || '--' },
{ label: '项目负责人', value: props.project.managerUserId || '--' }
];
});
</script>
<template>
<ElCard class="project-context-banner card-wrapper">
<template v-if="project">
<div class="flex flex-col gap-20px lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="mb-12px flex flex-wrap items-center gap-10px">
<span class="project-context-banner__code">{{ project.projectCode }}</span>
<ElTag v-if="projectStatusCode" :type="getProjectStatusTagType(projectStatusCode)" effect="light" round>
{{ getProjectStatusLabel(projectStatusCode) }}
</ElTag>
</div>
<div class="mb-10px flex flex-wrap items-center gap-12px">
<h2 class="text-24px text-[#0f172a] font-700">{{ project.projectName }}</h2>
<span v-if="caption" class="text-14px text-[#64748b]">{{ caption }}</span>
</div>
<div class="flex flex-wrap gap-x-18px gap-y-8px text-13px text-[#64748b] leading-22px">
<span>对象 ID{{ project.id || '--' }}</span>
<span>类型{{ project.projectType || '--' }}</span>
<span>项目负责人{{ project.managerUserId || '--' }}</span>
</div>
</div>
<div class="project-context-banner__stats">
<div v-for="item in summaryItems" :key="item.label" class="project-context-banner__stat-card">
<span class="project-context-banner__stat-label">{{ item.label }}</span>
<strong class="project-context-banner__stat-value">{{ item.value }}</strong>
</div>
</div>
</div>
</template>
<ElEmpty v-else description="未获取到当前项目上下文" :image-size="84" />
</ElCard>
</template>
<style scoped>
.project-context-banner {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 88%);
background:
radial-gradient(circle at top left, rgb(14 165 233 / 10%), transparent 32%),
linear-gradient(135deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
}
.project-context-banner__code {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background-color: rgb(15 23 42 / 88%);
color: #fff;
font-size: 12px;
letter-spacing: 0.08em;
}
.project-context-banner__stats {
display: grid;
flex-shrink: 0;
grid-template-columns: repeat(2, minmax(132px, 1fr));
gap: 12px;
width: min(100%, 320px);
}
.project-context-banner__stat-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border: 1px solid rgb(148 163 184 / 18%);
border-radius: 16px;
background-color: rgb(255 255 255 / 72%);
}
.project-context-banner__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.project-context-banner__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 20px;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,9 @@
export interface CurrentProjectSummary {
id: string;
projectCode: string;
projectName: string;
projectType: string;
productId: string | null;
managerUserId: string;
statusCode: string;
}

View File

@@ -0,0 +1,87 @@
import { transformRecordToOption } from '@/utils/common';
/** 项目状态编码与中文标签映射 */
export const projectStatusRecord: Record<Api.Project.ProjectStatusCode, string> = {
pending: '待开始',
active: '进行中',
paused: '已暂停',
completed: '已完成',
cancelled: '已取消',
archived: '已归档'
};
export const projectStatusOptions = transformRecordToOption(projectStatusRecord);
/** 项目状态动作编码与中文标签映射 */
export const projectStatusActionRecord: Record<Api.Project.ProjectStatusActionCode, string> = {
auto_start: '自动开始',
pause: '暂停项目',
resume: '恢复项目',
complete: '完成项目',
cancel: '取消项目',
reopen: '重新开启',
archive: '归档项目'
};
export function getProjectStatusLabel(status: Api.Project.ProjectStatusCode) {
return projectStatusRecord[status];
}
/** 根据项目状态返回对应的 Tag 类型,用于 ElTag 组件的颜色映射 */
export function getProjectStatusTagType(status: Api.Project.ProjectStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Project.ProjectStatusCode, UI.ThemeColor> = {
pending: 'info',
active: 'success',
paused: 'warning',
completed: 'success',
cancelled: 'info',
archived: 'info'
};
return statusTagTypeMap[status];
}
/** 判断项目是否可编辑pending / active / paused 状态允许编辑 */
export function isProjectEditable(status: Api.Project.ProjectStatusCode) {
return status === 'active' || status === 'pending';
}
/** 判断项目编辑是否受限paused / completed 状态只能编辑部分字段 */
export function isProjectEditLimited(status: Api.Project.ProjectStatusCode) {
return status === 'paused' || status === 'completed';
}
/** 根据当前状态获取允许的状态动作列表 */
export function getAllowedProjectStatusActions(
status: Api.Project.ProjectStatusCode
): Array<Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>> {
const actionMap: Record<
Api.Project.ProjectStatusCode,
Array<Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>>
> = {
pending: ['cancel'],
active: ['pause', 'complete', 'cancel'],
paused: ['resume', 'cancel'],
completed: ['reopen', 'archive'],
cancelled: [],
archived: []
};
return actionMap[status];
}
export function getProjectStatusActionLabel(actionCode: Api.Project.ProjectStatusActionCode) {
return projectStatusActionRecord[actionCode];
}
export function getProjectStatusActionOptions(status: Api.Project.ProjectStatusCode) {
return getAllowedProjectStatusActions(status).map(actionCode => ({
value: actionCode,
label: getProjectStatusActionLabel(actionCode)
}));
}
/** 判断状态动作是否必须填写原因resume 和 auto_start 不需要原因 */
export function isProjectActionReasonRequired(actionCode: Api.Project.ProjectStatusActionCode) {
return actionCode !== 'resume' && actionCode !== 'auto_start';
}

View File

@@ -0,0 +1,42 @@
import { computed } from 'vue';
import { useObjectContextStore } from '@/store/modules/object-context';
/**
* 获取当前项目上下文
*/
export function useCurrentProject() {
const objectContextStore = useObjectContextStore();
const currentObjectId = computed(() => objectContextStore.objectId);
const currentProject = computed(() => {
const summary = objectContextStore.objectSummary;
if (!summary) {
return null;
}
return ((summary as unknown as Api.Project.ProjectContext).currentProject || null) as
| Api.Project.ProjectContext['currentProject']
| null;
});
const currentRole = computed(() => {
const summary = objectContextStore.objectSummary;
if (!summary) {
return null;
}
return (summary as unknown as Api.Project.ProjectContext).currentRole || null;
});
const isGuest = computed(() => currentRole.value?.guestFlag ?? true);
return {
currentObjectId,
currentProject,
currentRole,
isGuest
};
}