feat(项目需求): 开发项目需求的功能。

This commit is contained in:
dk
2026-05-13 21:13:21 +08:00
parent 28c47b14a3
commit 60debcda8a
19 changed files with 3562 additions and 92 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang="tsx">
import { computed, markRaw, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { TableInstance } from 'element-plus';
import { ElButton, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs';
@@ -17,7 +18,8 @@ import {
fetchGetRequirementAllowedTransitions,
fetchGetRequirementStatusDict,
fetchGetRequirementTerminalStatusDict,
fetchGetRequirementTree
fetchGetRequirementTree,
fetchHasDispatchedProjectRequirement
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useDict } from '@/hooks/business/dict';
@@ -25,6 +27,8 @@ import DictTag from '@/components/custom/dict-tag.vue';
import DictText from '@/components/custom/dict-text.vue';
import { useCurrentProduct } from '../shared/use-current-product';
import {
ACTION_ICON_MAP,
ACTION_TYPE_MAP,
type RequirementStatusActionCode,
getRequirementActionDisplayName,
getRequirementStatusTagType,
@@ -38,60 +42,14 @@ import RequirementCreateDialog from './modules/requirement-create-dialog.vue';
import RequirementDetailDialog from './modules/requirement-detail-dialog.vue';
import RequirementSplitDialog from './modules/requirement-split-dialog.vue';
import RequirementActionDialog from './modules/requirement-action-dialog.vue';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiCheckOutline from '~icons/mdi/check-outline';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiSync from '~icons/mdi/sync';
import IconMdiPowerSettingsNew from '~icons/mdi/power-settings-new';
import IconMdiArrowSplitVertical from '~icons/mdi/arrow-split-vertical';
import IconMdiBookOpenPageVariantOutline from '~icons/mdi/book-open-page-variant-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconTablerSitemap from '~icons/tabler/sitemap';
defineOptions({ name: 'ProductRequirement' });
const router = useRouter();
const { currentObjectId } = useCurrentProduct();
const { hasObjectAuth } = useAuth();
/**
* 操作按钮图标映射
*
* 将操作类型映射到对应的 Iconify 图标组件
*/
const ACTION_ICON_MAP: Record<string, object> = {
split: markRaw(IconTablerSitemap),
edit: markRaw(IconMdiPencilOutline),
claim_to_review: markRaw(IconMdiCheckOutline),
claim_to_dispatch: markRaw(IconMdiCheckOutline),
to_dispatch: markRaw(IconMdiBookOpenPageVariantOutline),
dispatch: markRaw(IconMdiArrowSplitVertical),
accept: markRaw(IconMdiCheckCircleOutline),
reject: markRaw(IconMdiCloseCircleOutline),
cancel: markRaw(IconMdiCloseCircleOutline),
close: markRaw(IconMdiPowerSettingsNew),
delete: markRaw(IconMdiDeleteOutline)
};
/**
* 操作按钮颜色类型映射
*
* 审批/成功类操作 → success危险操作 → danger其他 → primary
*/
const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> = {
split: 'primary',
edit: 'primary',
claim_to_review: 'primary',
claim_to_dispatch: 'primary',
to_dispatch: 'primary',
dispatch: 'primary',
accept: 'primary',
reject: 'danger',
cancel: 'danger',
close: 'danger',
delete: 'danger'
};
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
const terminalStatusOptions = ref<string[]>([]);
const projectOptions = ref<Api.Project.Project[]>([]);
@@ -154,6 +112,7 @@ const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
2: 'warning',
3: 'danger'
};
const hasDispatchedMap = ref<Record<string, boolean>>({});
function formatDateTime(value?: string | null) {
if (!value) {
@@ -168,6 +127,13 @@ function isTerminalStatus(statusCode: string) {
}
function canSplitRequirement(row: Api.Product.Requirement) {
if (row.implementProjectId) {
return false;
}
const hasDispatched = hasDispatchedMap.value[row.id];
if (hasDispatched) {
return false;
}
return row.statusCode === 'pending_dispatch' || row.statusCode === 'implementing';
}
@@ -268,17 +234,50 @@ function collectAllRequirementIds(nodes: Api.Product.Requirement[]): string[] {
return ids;
}
function collectRequirementIdsForActions(nodes: Api.Product.Requirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
const isTerminal = isTerminalStatus(node.statusCode);
const hasDispatched = Boolean(node.implementProjectId);
if (!isTerminal && !hasDispatched) {
ids.push(node.id);
}
if (node.children?.length) {
ids.push(...collectRequirementIdsForActions(node.children));
}
}
return ids;
}
function collectRequirementIdsForSplitCheck(nodes: Api.Product.Requirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
if (!node.implementProjectId) {
ids.push(node.id);
}
if (node.children?.length) {
ids.push(...collectRequirementIdsForSplitCheck(node.children));
}
}
return ids;
}
async function loadAllowedTransitionsForAll() {
if (!currentObjectId.value) {
allowedTransitionsMap.value = new Map();
return;
}
const allIds = collectAllRequirementIds(treeData.value);
const idsToQuery = collectRequirementIdsForActions(treeData.value);
const newMap = new Map<string, Api.Product.RequirementLifecycleAction[]>();
if (idsToQuery.length === 0) {
allowedTransitionsMap.value = newMap;
return;
}
const results = await Promise.all(
allIds.map(async id => {
idsToQuery.map(async id => {
const { error, data } = await fetchGetRequirementAllowedTransitions(id, currentObjectId.value!);
return { id, actions: error ? [] : data || [] };
})
@@ -291,6 +290,30 @@ async function loadAllowedTransitionsForAll() {
allowedTransitionsMap.value = newMap;
}
async function loadHasDispatchedForAll() {
if (!currentObjectId.value) {
hasDispatchedMap.value = {};
return;
}
const idsToQuery = collectRequirementIdsForSplitCheck(treeData.value);
const newMap: Record<string, boolean> = {};
if (idsToQuery.length === 0) {
hasDispatchedMap.value = newMap;
return;
}
await Promise.all(
idsToQuery.map(async id => {
const { data } = await fetchHasDispatchedProjectRequirement(id, currentObjectId.value!);
newMap[id] = Boolean(data);
})
);
hasDispatchedMap.value = newMap;
}
function getRowActions(row: Api.Product.Requirement): Api.Product.RequirementLifecycleAction[] {
return allowedTransitionsMap.value.get(row.id) || [];
}
@@ -408,7 +431,12 @@ const columns = computed(() => [
minWidth: 140,
formatter: (row: Api.Product.Requirement) => {
if (!row.implementProjectId) return '--';
return projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
const projectName = projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
return (
<ElButton link type="primary" class="implement-project-link" onClick={() => handleImplementProjectClick(row)}>
{projectName}
</ElButton>
);
}
},
{
@@ -433,7 +461,7 @@ const columns = computed(() => [
onClick: () => void;
}[] = [];
if (canSplitRequirement(row) && hasObjectAuth('project:product:status')) {
if (canSplitRequirement(row) && hasObjectAuth('project:product:split')) {
actions.push({
key: 'split',
label: '拆分',
@@ -443,7 +471,12 @@ const columns = computed(() => [
});
}
if (hasObjectAuth('project:product:update') && !isTerminalStatus(row.statusCode)) {
if (
hasObjectAuth('project:product:update') &&
!isTerminalStatus(row.statusCode) &&
row.statusCode !== 'accepted' &&
!row.implementProjectId
) {
actions.push({
key: 'edit',
label: '编辑',
@@ -480,7 +513,7 @@ const columns = computed(() => [
}
}
if (hasStatusAuth && canDeleteRequirement(row)) {
if (canDeleteRequirement(row) && hasObjectAuth('project:product:delete')) {
actions.push({
key: 'delete',
label: '删除',
@@ -566,8 +599,6 @@ async function loadTreeData() {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirementTree({
productId: currentObjectId.value,
moduleId: selectedModuleId.value,
@@ -581,8 +612,6 @@ async function loadTreeData() {
sourceType: searchParams.sourceType
});
loading.value = false;
if (error || !data) {
treeData.value = [];
pagination.total = 0;
@@ -594,8 +623,14 @@ async function loadTreeData() {
}
async function reloadTable() {
await loadTreeData();
await loadAllowedTransitionsForAll();
loading.value = true;
try {
await loadTreeData();
await loadAllowedTransitionsForAll();
await loadHasDispatchedForAll();
} finally {
loading.value = false;
}
}
function handleModuleSelect(moduleId: string | undefined) {
@@ -653,6 +688,17 @@ function openSplit(row: Api.Product.Requirement) {
splitVisible.value = true;
}
async function handleImplementProjectClick(row: Api.Product.Requirement) {
if (!row.implementProjectId) return;
router.push({
path: '/project/project/requirement',
query: {
objectId: row.implementProjectId
}
});
}
function handleActionClick(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
const actionCode = action.actionCode as RequirementStatusActionCode;
@@ -764,6 +810,7 @@ watch(
if (id) {
await Promise.all([loadMembers(), loadTreeData(), loadProjectOptions()]);
await loadAllowedTransitionsForAll();
await loadHasDispatchedForAll();
} else {
memberOptions.value = [];
treeData.value = [];
@@ -919,6 +966,11 @@ onMounted(async () => {
padding: 0;
}
:deep(.implement-project-link) {
padding: 0;
font-weight: 500;
}
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
color: transparent;
}

View File

@@ -116,9 +116,15 @@ async function handleSubmit() {
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElFormItem :label="`需求名称:${requirementTitle}`"></ElFormItem>
<ElAlert
v-if="requirementTitle"
:title="`需求名称:${requirementTitle}`"
type="info"
:closable="false"
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElFormItem v-if="isClaimAction" label="是否需要评审" prop="reviewChoice">
<ElRadioGroup v-model="model.reviewChoice" class="business-form-radio-group">
<ElRadio v-for="option in reviewChoiceOptions" :key="option.value" :value="option.value">

View File

@@ -404,21 +404,9 @@ watch(
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol v-if="isViewMode" :span="12">
<ElFormItem label="实现项目">
<template v-if="isViewMode">
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
</template>
<ElSelect
v-else
v-model="model.implementProjectId"
class="w-full"
filterable
clearable
placeholder="请选择实现项目"
>
<ElOption v-for="item in projectOptions" :key="item.id" :label="item.projectName" :value="item.id" />
</ElSelect>
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetRequirementStatusDict } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -33,6 +34,17 @@ const model = defineModel<Api.Product.RequirementSearchParams>('model', { requir
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
const { enabledDictData: sourceTypeDictData } = useDict(RDMS_REQ_SOURCE_TYPE_DICT_CODE);
const sourceTypeOptions = computed(() => {
return sourceTypeDictData.value
.filter(item => item.value !== 'product_requirement')
.map(item => ({
label: item.label,
value: item.value
}));
});
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
@@ -112,12 +124,9 @@ onMounted(async () => {
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="需求来源">
<DictSelect
v-model="model.sourceType"
:dict-code="RDMS_REQ_SOURCE_TYPE_DICT_CODE"
clearable
placeholder="筛选需求来源"
/>
<ElSelect v-model="model.sourceType" clearable placeholder="筛选需求来源">
<ElOption v-for="item in sourceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
</TableSearchPanel>

View File

@@ -149,6 +149,11 @@ watch(
model.value.category = props.parentRequirement.category;
}
// 默认选中父需求的负责人
if (props.parentRequirement?.currentHandlerUserId) {
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
}
await nextTick();
formRef.value?.clearValidate();
}

View File

@@ -1,4 +1,17 @@
import { markRaw } from 'vue';
import { transformRecordToOption } from '@/utils/common';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiCheckOutline from '~icons/mdi/check-outline';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiSync from '~icons/mdi/sync';
import IconMdiPowerSettingsNew from '~icons/mdi/power-settings-new';
import IconMdiShareVariant from '~icons/mdi/share-variant';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiGlasses from '~icons/mdi/glasses';
import IconMdiClose from '~icons/mdi/close';
import IconTablerSitemap from '~icons/tabler/sitemap';
import IconTablerCircleX from '~icons/tabler/circle-x';
import IconMaterialSymbolsDescriptionOutline from '~icons/material-symbols/description-outline';
export type RequirementStatusActionCode =
| 'claim_to_review'
@@ -34,6 +47,44 @@ export const requirementStatusActionRecord: Record<RequirementStatusActionCode,
close: '关闭'
};
/**
* 操作按钮图标映射
*
* 将操作类型映射到对应的 Iconify 图标组件
*/
export const ACTION_ICON_MAP: Record<string, object> = {
split: markRaw(IconTablerSitemap),
edit: markRaw(IconMdiPencilOutline),
claim_to_review: markRaw(IconMaterialSymbolsDescriptionOutline),
claim_to_dispatch: markRaw(IconMdiCheckOutline),
to_dispatch: markRaw(IconMdiGlasses),
dispatch: markRaw(IconMdiShareVariant),
accept: markRaw(IconMdiCheckCircleOutline),
reject: markRaw(IconMdiClose),
cancel: markRaw(IconTablerCircleX),
close: markRaw(IconMdiPowerSettingsNew),
delete: markRaw(IconMdiDeleteOutline)
};
/**
* 操作按钮颜色类型映射
*
* 审批/成功类操作 → success危险操作 → danger其他 → primary
*/
export const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> = {
split: 'primary',
edit: 'primary',
claim_to_review: 'primary',
claim_to_dispatch: 'primary',
to_dispatch: 'primary',
dispatch: 'primary',
accept: 'primary',
reject: 'danger',
cancel: 'danger',
close: 'danger',
delete: 'danger'
};
export function getRequirementStatusLabel(status: Api.Product.RequirementStatusCode) {
return requirementStatusRecord[status];
}