feat(项目需求): 开发项目需求的功能。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user