fix(产品需求、项目需求): 按照会议所说进行修改。

This commit is contained in:
dk
2026-05-18 16:49:12 +08:00
parent 023490c012
commit 2367e03146
32 changed files with 1065 additions and 591 deletions

View File

@@ -15,11 +15,11 @@ import {
fetchDeleteRequirement,
fetchGetProductMembers,
fetchGetProjectListByProductId,
fetchGetRequirementAllowedTransitions,
fetchGetRequirementAllowedTransitionsBatch,
fetchGetRequirementStatusDict,
fetchGetRequirementTerminalStatusDict,
fetchGetRequirementTree,
fetchHasDispatchedProjectRequirement
fetchHasDispatchedProjectRequirementBatch
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useDict } from '@/hooks/business/dict';
@@ -107,10 +107,10 @@ function getStatusLabel(statusCode: string) {
}
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
0: 'info',
1: 'primary',
2: 'warning',
3: 'danger'
0: 'danger',
1: 'warning',
2: 'primary',
3: 'info'
};
const hasDispatchedMap = ref<Record<string, boolean>>({});
@@ -122,6 +122,14 @@ function formatDateTime(value?: string | null) {
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
function formatDate(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD');
}
function isTerminalStatus(statusCode: string) {
return terminalStatusOptions.value.includes(statusCode);
}
@@ -223,17 +231,6 @@ function flattenTree(nodes: Api.Product.Requirement[]): Api.Product.Requirement[
return result;
}
function collectAllRequirementIds(nodes: Api.Product.Requirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
ids.push(node.id);
if (node.children?.length) {
ids.push(...collectAllRequirementIds(node.children));
}
}
return ids;
}
function collectRequirementIdsForActions(nodes: Api.Product.Requirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
@@ -276,15 +273,18 @@ async function loadAllowedTransitionsForAll() {
return;
}
const results = await Promise.all(
idsToQuery.map(async id => {
const { error, data } = await fetchGetRequirementAllowedTransitions(id, currentObjectId.value!);
return { id, actions: error ? [] : data || [] };
})
);
const { error, data } = await fetchGetRequirementAllowedTransitionsBatch({
productId: currentObjectId.value,
requirementIds: idsToQuery
});
for (const { id, actions } of results) {
newMap.set(id, actions);
if (error || !data) {
allowedTransitionsMap.value = newMap;
return;
}
for (const item of data) {
newMap.set(item.requirementId, item.transitions || []);
}
allowedTransitionsMap.value = newMap;
@@ -304,12 +304,19 @@ async function loadHasDispatchedForAll() {
return;
}
await Promise.all(
idsToQuery.map(async id => {
const { data } = await fetchHasDispatchedProjectRequirement(id, currentObjectId.value!);
newMap[id] = Boolean(data);
})
);
const { error, data } = await fetchHasDispatchedProjectRequirementBatch({
productId: currentObjectId.value,
requirementIds: idsToQuery
});
if (error || !data) {
hasDispatchedMap.value = newMap;
return;
}
for (const item of data) {
newMap[item.requirementId] = Boolean(item.hasDispatched);
}
hasDispatchedMap.value = newMap;
}
@@ -339,12 +346,12 @@ const columns = computed(() => [
label: '需求名称',
minWidth: 200,
formatter: (row: Api.Product.Requirement) => {
const className = 'requirement-title';
return (
<ElButton link type="primary" class={className} onClick={() => openView(row)}>
{row.title}
</ElButton>
<ElTooltip content={row.title} placement="top" show-after={300}>
<ElButton link type="primary" class="requirement-title" onClick={() => openView(row)}>
{row.title}
</ElButton>
</ElTooltip>
);
}
},
@@ -360,19 +367,12 @@ const columns = computed(() => [
{
prop: 'statusCode',
label: '状态',
width: 100,
width: 90,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<ElTag type={getRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'workHours',
label: '所需工时',
width: 75,
align: 'center',
formatter: (row: Api.Product.Requirement) => (row.workHours !== null ? `${row.workHours}h` : '--')
},
{
prop: 'category',
label: '需求类型',
@@ -400,13 +400,13 @@ const columns = computed(() => [
{
prop: 'proposerNickname',
label: '提出人',
minWidth: 70,
minWidth: 85,
formatter: (row: Api.Product.Requirement) => row.proposerNickname || '--'
},
{
prop: 'currentHandlerUserId',
label: '负责人',
minWidth: 70,
minWidth: 85,
formatter: (row: Api.Product.Requirement) => getMemberLabel(row.currentHandlerUserId)
},
{
@@ -427,22 +427,24 @@ const columns = computed(() => [
},
{
prop: 'implementProjectId',
label: '实现项目',
minWidth: 140,
label: '关联项目',
minWidth: 180,
formatter: (row: Api.Product.Requirement) => {
if (!row.implementProjectId) return '--';
const projectName = projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
return (
<ElButton link type="primary" class="implement-project-link" onClick={() => handleImplementProjectClick(row)}>
{projectName}
</ElButton>
);
return projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
}
},
{
prop: 'expectedTime',
label: '预期完成时间',
minWidth: 120,
align: 'center',
formatter: (row: Api.Product.Requirement) => formatDate(row.expectedTime)
},
{
prop: 'createTime',
label: '创建时间',
minWidth: 180,
minWidth: 120,
formatter: (row: Api.Product.Requirement) => formatDateTime(row.createTime)
},
{
@@ -461,31 +463,39 @@ const columns = computed(() => [
onClick: () => void;
}[] = [];
if (canSplitRequirement(row) && hasObjectAuth('project:product:split')) {
if (hasObjectAuth('project:product:split')) {
actions.push({
key: 'split',
label: '拆分',
icon: ACTION_ICON_MAP.split,
type: ACTION_TYPE_MAP.split,
disabled: !canSplitRequirement(row),
onClick: () => openSplit(row)
});
}
if (
hasObjectAuth('project:product:update') &&
!isTerminalStatus(row.statusCode) &&
row.statusCode !== 'accepted' &&
!row.implementProjectId
) {
if (hasObjectAuth('project:product:update')) {
const canEdit = !isTerminalStatus(row.statusCode) && row.statusCode !== 'accepted' && !row.implementProjectId;
actions.push({
key: 'edit',
label: '编辑',
icon: ACTION_ICON_MAP.edit,
type: ACTION_TYPE_MAP.edit,
disabled: !canEdit,
onClick: () => openEdit(row)
});
}
if (row.implementProjectId) {
actions.push({
key: 'forward',
label: '前往项目侧',
icon: ACTION_ICON_MAP.forward,
type: 'primary',
onClick: () => handleForwardToProjectRequirement(row)
});
}
const lifecycleActions = getRowActions(row);
const hasStatusAuth = hasObjectAuth('project:product:status');
@@ -534,6 +544,7 @@ const columns = computed(() => [
size="small"
class="requirement-action-icon-btn"
type={action.type}
disabled={action.disabled}
onClick={() => action.onClick()}
>
<IconComponent class="text-18px" />
@@ -626,8 +637,7 @@ async function reloadTable() {
loading.value = true;
try {
await loadTreeData();
await loadAllowedTransitionsForAll();
await loadHasDispatchedForAll();
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll()]);
} finally {
loading.value = false;
}
@@ -688,10 +698,10 @@ function openSplit(row: Api.Product.Requirement) {
splitVisible.value = true;
}
async function handleImplementProjectClick(row: Api.Product.Requirement) {
async function handleForwardToProjectRequirement(row: Api.Product.Requirement) {
if (!row.implementProjectId) return;
router.push({
await router.replace({
path: '/project/project/requirement',
query: {
objectId: row.implementProjectId
@@ -809,8 +819,7 @@ watch(
async id => {
if (id) {
await Promise.all([loadMembers(), loadTreeData(), loadProjectOptions()]);
await loadAllowedTransitionsForAll();
await loadHasDispatchedForAll();
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll()]);
} else {
memberOptions.value = [];
treeData.value = [];
@@ -954,6 +963,10 @@ onMounted(async () => {
:deep(.requirement-title) {
padding: 0;
font-weight: 500;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.requirement-title--terminal) {
@@ -966,11 +979,6 @@ 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

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { type Ref, computed, inject, ref } from 'vue';
import { useAuth } from '@/hooks/business/auth';
defineOptions({ name: 'ModuleTreeNode' });
@@ -32,10 +33,23 @@ const emit = defineEmits([
'updateNewChildModuleName'
]);
const { hasObjectAuth } = useAuth();
const isRootModule = computed(() => props.module.id === props.rootModuleId);
const hasAnyActionPermission = computed(() => {
if (isRootModule.value) {
return hasObjectAuth('project:product:create');
}
return (
hasObjectAuth('project:product:create') ||
hasObjectAuth('project:product:update') ||
hasObjectAuth('project:product:delete')
);
});
const collapsedModuleIds = inject<Ref<Set<string>>>('collapsedModuleIds', ref(new Set()));
const toggleCollapse = inject<(id: string) => void>('toggleCollapse', () => {});
const isRootModule = computed(() => props.module.id === props.rootModuleId);
const isSelected = computed(() => props.selectedModuleId === props.module.id);
const isEditing = computed(() => props.editingNodeId === props.module.id);
const isAddingChild = computed(() => props.addingChildParentId === props.module.id);
@@ -141,35 +155,27 @@ function handleToggle() {
/>
</div>
<div v-if="!isEditing" class="module-tree-item__actions" @click.stop>
<div v-if="!isEditing && hasAnyActionPermission" class="module-tree-item__actions" @click.stop>
<ElDropdown trigger="click">
<ElButton text size="small" class="module-tree-item__more-btn">
<icon-mdi-dots-horizontal class="text-14px" />
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-auth="{ code: 'project:product:create', source: 'object' }"
@click="handleStartAddChild"
>
<ElDropdownItem v-if="hasObjectAuth('project:product:create')" @click="handleStartAddChild">
<div class="flex items-center gap-6px">
<icon-ic-round-plus class="text-14px" />
<span>新增子模块</span>
</div>
</ElDropdownItem>
<ElDropdownItem
v-if="!isRootModule"
v-auth="{ code: 'project:product:update', source: 'object' }"
@click="handleStartEdit"
>
<ElDropdownItem v-if="!isRootModule && hasObjectAuth('project:product:update')" @click="handleStartEdit">
<div class="flex items-center gap-6px">
<icon-mdi-pencil-outline class="text-14px" />
<span>编辑</span>
</div>
</ElDropdownItem>
<ElDropdownItem
v-if="!isRootModule && canDeleteModule"
v-auth="{ code: 'project:product:delete', source: 'object' }"
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:product:delete')"
divided
@click="handleDelete"
>
@@ -241,73 +247,112 @@ function handleToggle() {
.module-tree-node {
display: flex;
flex-direction: column;
gap: 10px;
}
.module-tree-item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
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%);
gap: 8px;
min-height: 36px;
padding: 6px 12px;
padding-left: 16px;
border-radius: 8px;
color: #475569;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
background-color 0.15s ease,
color 0.15s ease;
}
.module-tree-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 0;
border-radius: 0 2px 2px 0;
background-color: transparent;
transition:
height 0.15s ease,
background-color 0.15s ease;
}
.module-tree-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
background-color: #f1f5f9;
}
.module-tree-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;
background-color: #f0fdfa;
color: #0d9488;
font-weight: 500;
}
.module-tree-item.is-root:not(.is-active) .module-tree-item__icon {
color: rgb(13 148 136 / 80%);
.module-tree-item.is-active::before {
height: 60%;
background-color: #14b8a6;
}
.module-tree-item.is-root {
font-weight: 600;
color: #1e293b;
}
.module-tree-item.is-root:hover {
background-color: #f8fafc;
}
.module-tree-item.is-root.is-active {
background-color: #f0fdfa;
color: #0d9488;
}
.module-tree-item--new {
border-style: dashed;
border-color: rgb(148 163 184 / 56%);
border: 1px dashed #cbd5e1;
background-color: #f8fafc;
}
.module-tree-item__icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: rgb(100 116 139 / 80%);
.module-tree-item--new:hover {
background-color: #f1f5f9;
}
.module-tree-item__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
user-select: none;
transition: transform 0.2s ease;
color: rgb(148 163 184);
color: #94a3b8;
border-radius: 4px;
}
.module-tree-item__toggle:hover {
background-color: #e2e8f0;
color: #64748b;
}
.module-tree-item__toggle.is-expanded svg {
transform: rotate(90deg);
}
.module-tree-item__icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: #94a3b8;
}
.module-tree-item.is-active .module-tree-item__icon {
color: #14b8a6;
}
.module-tree-item__content {
flex: 1;
min-width: 0;
@@ -326,7 +371,7 @@ function handleToggle() {
}
.module-tree-item__input :deep(.el-input__inner) {
height: 28px;
height: 26px;
}
.module-tree-item__actions {
@@ -334,7 +379,7 @@ function handleToggle() {
align-items: center;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
transition: opacity 0.15s ease;
}
.module-tree-item:hover .module-tree-item__actions {
@@ -347,5 +392,10 @@ function handleToggle() {
.module-tree-item__more-btn {
padding: 4px;
border-radius: 4px;
}
.module-tree-item__more-btn:hover {
background-color: #e2e8f0;
}
</style>

View File

@@ -66,7 +66,7 @@ const rules = computed(() => {
}
if (isDispatchAction.value) {
baseRules.implementProjectId = [createRequiredRule('请选择实现项目')];
baseRules.implementProjectId = [createRequiredRule('请选择关联项目')];
}
if (isTerminalAction.value) {
@@ -136,8 +136,8 @@ async function handleSubmit() {
</ElRadioGroup>
</ElFormItem>
<ElFormItem v-if="isDispatchAction" label="实现项目" prop="implementProjectId">
<ElSelect v-model="model.implementProjectId" class="w-full" filterable placeholder="请选择实现项目(必选)">
<ElFormItem v-if="isDispatchAction" label="关联项目" prop="implementProjectId">
<ElSelect v-model="model.implementProjectId" class="w-full" filterable placeholder="请选择关联项目(必选)">
<ElOption v-for="item in projectOptions" :key="item.id" :label="item.projectName" :value="item.id" />
</ElSelect>
</ElFormItem>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
@@ -47,6 +48,31 @@ const priorityOptions = computed(() => {
}));
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
function buildExpectedTimeShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
const date = new Date();
mutator(date);
return date;
}
};
}
const expectedTimeShortcuts = [
buildExpectedTimeShortcut('一星期', date => date.setDate(date.getDate() + 7)),
buildExpectedTimeShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildExpectedTimeShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
buildExpectedTimeShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
buildExpectedTimeShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
buildExpectedTimeShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
interface Model {
title: string;
description: string | null;
@@ -55,9 +81,9 @@ interface Model {
moduleId: string;
category: string;
priority: number | null;
expectedTime: string | null;
proposerId: string;
currentHandlerUserId: string;
workHours: number | null;
sort: number;
}
@@ -93,18 +119,12 @@ const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() =
return options;
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入需求名称')],
category: [createRequiredRule('请选择需求类型')],
priority: [createRequiredRule('请选择优先级')],
proposerId: [createRequiredRule('请选择提出人')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
workHours: [createRequiredRule('请输入所需工时')]
currentHandlerUserId: [createRequiredRule('请选择负责人')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
@@ -146,9 +166,9 @@ function createDefaultModel(): Model {
moduleId: props.defaultModuleId || '0',
category: '功能需求',
priority: 1,
expectedTime: null,
proposerId: '',
currentHandlerUserId: '',
workHours: null,
sort: 0
};
}
@@ -183,12 +203,12 @@ async function handleSubmit() {
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
expectedTime: model.value.expectedTime,
proposerId: model.value.proposerId,
proposerNickname,
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname,
implementProjectId: null,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
@@ -272,14 +292,17 @@ watch(
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
<ElRadioGroup v-model="model.reviewRequired">
<ElRadio
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
border
style="width: 165px"
>
{{ item.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
@@ -288,17 +311,6 @@ watch(
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<DictSelect
v-model="model.category"
@@ -334,9 +346,20 @@ watch(
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
<ElFormItem label="预期完成时间">
<ElDatePicker
v-model="model.expectedTime"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择预期完成时间"
:shortcuts="expectedTimeShortcuts"
class="requirement-operate-dialog__date-picker"
/>
</ElFormItem>
<!-- <ElFormItem label="排序值">-->
<!-- <ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />-->
<!-- </ElFormItem>-->
</BusinessFormSection>
</div>
@@ -397,4 +420,8 @@ watch(
grid-template-columns: 1fr;
}
}
:deep(.requirement-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import {
fetchGetProjectListByProductId,
fetchGetRequirement,
@@ -65,12 +66,12 @@ interface Model {
moduleId: string;
category: string;
priority: number | null;
expectedTime: string | null;
proposerId: string;
proposerNickname: string;
currentHandlerUserId: string;
currentHandlerUserNickname: string;
implementProjectId: string | null;
workHours: number | null;
sort: number;
lastStatusReason: string;
}
@@ -147,6 +148,26 @@ const reviewRequiredOptions = [
{ label: '需要', value: 1 }
];
function buildExpectedTimeShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
const date = new Date();
mutator(date);
return date;
}
};
}
const expectedTimeShortcuts = [
buildExpectedTimeShortcut('一星期', date => date.setDate(date.getDate() + 7)),
buildExpectedTimeShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildExpectedTimeShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
buildExpectedTimeShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
buildExpectedTimeShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
buildExpectedTimeShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = {
title: isEditMode.value ? [createRequiredRule('请输入需求名称')] : [],
@@ -198,12 +219,12 @@ function createDefaultModel(): Model {
moduleId: '0',
category: '',
priority: 1,
expectedTime: null,
proposerId: '',
proposerNickname: '',
currentHandlerUserId: '',
currentHandlerUserNickname: '',
implementProjectId: null,
workHours: null,
sort: 0,
lastStatusReason: ''
};
@@ -239,12 +260,12 @@ async function handleSubmit() {
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
expectedTime: model.value.expectedTime,
proposerId: model.value.proposerId,
proposerNickname: model.value.proposerNickname,
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: handler?.userNickname || model.value.currentHandlerUserNickname,
implementProjectId: model.value.implementProjectId,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
@@ -304,17 +325,30 @@ function transformRequirementData(data: Api.Product.Requirement): typeof model.v
moduleId: data.moduleId || '0',
category: data.category || '',
priority: data.priority ?? null,
expectedTime: formatExpectedTime(data.expectedTime),
proposerId: data.proposerId || '',
proposerNickname: data.proposerNickname || '',
currentHandlerUserId: data.currentHandlerUserId || '',
currentHandlerUserNickname: data.currentHandlerUserNickname || '',
implementProjectId: data.implementProjectId || null,
workHours: data.workHours ?? null,
sort: data.sort ?? 0,
lastStatusReason: data.lastStatusReason || ''
};
}
function formatExpectedTime(value?: string | number[] | null): string | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
const [year, month, day] = value;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
return dayjs(value).format('YYYY-MM-DD');
}
async function loadRequirementDetail() {
if (!props.productId || !props.requirement?.id) {
return;
@@ -402,21 +436,6 @@ watch(
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时">
<template v-if="isViewMode">
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
</template>
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</ElFormItem>
@@ -447,24 +466,37 @@ watch(
</ElSelect>
</ElFormItem>
<ElFormItem v-if="isViewMode" label="实现项目">
<ElFormItem v-if="isViewMode" label="关联项目">
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
</ElFormItem>
<ElFormItem label="排序值">
<template v-if="isViewMode">
<ReadonlyField :value="model.sort" />
</template>
<ElInputNumber
<ElFormItem label="预期完成时间">
<ReadonlyField v-if="isViewMode" :value="model.expectedTime || '--'" />
<ElDatePicker
v-else
v-model="model.sort"
class="w-full"
:min="0"
:max="9999"
placeholder="请输入排序值"
v-model="model.expectedTime"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择预期完成时间"
:shortcuts="expectedTimeShortcuts"
class="requirement-operate-dialog__date-picker"
/>
</ElFormItem>
<!-- <ElFormItem label="排序值">-->
<!-- <template v-if="isViewMode">-->
<!-- <ReadonlyField :value="model.sort" />-->
<!-- </template>-->
<!-- <ElInputNumber-->
<!-- v-else-->
<!-- v-model="model.sort"-->
<!-- class="w-full"-->
<!-- :min="0"-->
<!-- :max="9999"-->
<!-- placeholder="请输入排序值"-->
<!-- />-->
<!-- </ElFormItem>-->
<ElFormItem v-if="isViewMode && model.lastStatusReason" label="状态变更原因">
<div class="requirement-operate-dialog__readonly-textarea">{{ model.lastStatusReason }}</div>
</ElFormItem>
@@ -528,7 +560,7 @@ watch(
.requirement-operate-dialog__readonly-textarea {
box-sizing: border-box;
width: 100%;
min-height: 100px;
min-height: 65px;
padding: 8px 12px;
border-radius: 4px;
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
@@ -545,4 +577,8 @@ watch(
grid-template-columns: 1fr;
}
}
:deep(.requirement-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -216,15 +216,11 @@ async function handleDeleteModule(module: Api.Product.RequirementModule) {
if (!currentObjectId.value) return;
try {
await ElMessageBox.confirm(
`确定要删除模块 "${module.moduleName}" 吗?该模块下的所有需求将被一并删除。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
);
await ElMessageBox.confirm(`确定要删除模块 "${module.moduleName}" 吗?`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
@@ -310,12 +306,12 @@ defineExpose({
.requirement-module-tree-card :deep(.el-card__header) {
padding: 12px 16px;
border-bottom: none;
border-bottom: 1px solid #f1f5f9;
}
.requirement-module-tree-card :deep(.el-card__body) {
padding: 0 16px 16px;
height: calc(100% - 48px);
padding: 12px 8px;
height: calc(100% - 49px);
overflow: hidden;
}
@@ -326,68 +322,34 @@ defineExpose({
}
.module-tree-header__title {
color: rgb(15 23 42 / 94%);
font-size: 16px;
font-weight: 700;
color: #1e293b;
font-size: 15px;
font-weight: 600;
}
.module-tree-list {
display: flex;
flex-direction: column;
gap: 10px;
gap: 2px;
min-height: 0;
height: 100%;
overflow-y: auto;
}
.module-tree-item {
display: flex;
align-items: center;
gap: 10px;
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;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
.module-tree-list::-webkit-scrollbar {
width: 4px;
}
.module-tree-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
.module-tree-list::-webkit-scrollbar-track {
background: transparent;
}
.module-tree-item--new {
border-style: dashed;
border-color: rgb(148 163 184 / 56%);
.module-tree-list::-webkit-scrollbar-thumb {
background-color: #e2e8f0;
border-radius: 2px;
}
.module-tree-item__icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: rgb(100 116 139 / 80%);
}
.module-tree-item__content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.module-tree-item__input {
width: 100%;
}
.module-tree-item__input :deep(.el-input__inner) {
height: 28px;
.module-tree-list::-webkit-scrollbar-thumb:hover {
background-color: #cbd5e1;
}
</style>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, h, 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 TableSearchFields from '@/components/custom/table-search-fields.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementSearch' });
@@ -21,7 +20,7 @@ interface Props {
priorityDictCode: string;
}
defineProps<Props>();
const props = defineProps<Props>();
interface Emits {
(e: 'reset'): void;
@@ -45,6 +44,21 @@ const sourceTypeOptions = computed(() => {
}));
});
const memberSelectOptions = computed(() => {
return props.memberOptions.map(item => ({
label: item.nickname,
value: item.id,
roleName: item.roleName
}));
});
function renderMemberOption(option: { label: string; value: string | number; roleName?: string }) {
return h(MemberSelectOption, {
nickname: option.label,
roleName: option.roleName || ''
});
}
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
@@ -59,77 +73,58 @@ async function loadStatusOptions() {
}));
}
function reset() {
emit('reset');
}
function search() {
emit('search');
}
onMounted(async () => {
await loadStatusOptions();
});
const fields = computed(() => [
{
key: 'title',
label: '需求名称',
type: 'input' as const,
placeholder: '输入需求名称'
},
{
key: 'priority',
label: '优先级',
type: 'dict' as const,
dictCode: props.priorityDictCode,
placeholder: '筛选优先级'
},
{
key: 'statusCode',
label: '状态',
type: 'select' as const,
placeholder: '筛选状态',
options: requirementStatusOptions.value
},
{
key: 'category',
label: '需求类型',
type: 'dict' as const,
dictCode: props.categoryDictCode,
placeholder: '筛选需求类型'
},
{
key: 'sourceType',
label: '需求来源',
type: 'select' as const,
placeholder: '筛选需求来源',
options: sourceTypeOptions.value
},
{
key: 'currentHandlerUserId',
label: '负责人',
type: 'select' as const,
placeholder: '筛选负责人',
options: memberSelectOptions.value,
renderOption: renderMemberOption
}
]);
</script>
<template>
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="需求名称">
<ElInput v-model="model.title" clearable placeholder="输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="需求类型">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
clearable
filterable
placeholder="筛选需求类型"
/>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="优先级">
<DictSelect v-model="model.priority" :dict-code="priorityDictCode" clearable placeholder="筛选优先级" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="状态">
<ElSelect v-model="model.statusCode" clearable placeholder="筛选状态">
<ElOption
v-for="item in requirementStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="负责人">
<ElSelect
v-model="model.currentHandlerUserId"
clearable
filterable
placeholder="筛选负责人"
:filter-method="(val: string) => val"
>
<ElOption v-for="item in memberOptions" :key="item.id" :label="item.nickname" :value="item.id">
<MemberSelectOption :nickname="item.nickname" :role-name="item.roleName || ''" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="需求来源">
<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>
<TableSearchFields v-model="model" :fields="fields" :columns="3" @search="emit('search')" @reset="emit('reset')" />
</template>
<style scoped></style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { fetchSplitRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
@@ -46,6 +47,31 @@ const priorityOptions = computed(() => {
}));
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
function buildExpectedTimeShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
const date = new Date();
mutator(date);
return date;
}
};
}
const expectedTimeShortcuts = [
buildExpectedTimeShortcut('一星期', date => date.setDate(date.getDate() + 7)),
buildExpectedTimeShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildExpectedTimeShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
buildExpectedTimeShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
buildExpectedTimeShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
buildExpectedTimeShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
interface Model {
title: string;
description: string | null;
@@ -53,8 +79,8 @@ interface Model {
reviewRequired: number;
category: string;
priority: number | null;
expectedTime: string | null;
currentHandlerUserId: string;
workHours: number | null;
sort: number;
}
@@ -66,16 +92,10 @@ const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0);
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入子需求名称')],
priority: [createRequiredRule('请选择优先级')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
workHours: [createRequiredRule('请输入所需工时')]
currentHandlerUserId: [createRequiredRule('请选择负责人')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
@@ -116,8 +136,8 @@ function createDefaultModel(): Model {
reviewRequired: 0,
category: '',
priority: 1,
expectedTime: null,
currentHandlerUserId: '',
workHours: null,
sort: 0
};
}
@@ -153,8 +173,8 @@ async function handleSubmit() {
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
expectedTime: model.value.expectedTime,
currentHandlerUserId: model.value.currentHandlerUserId,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
@@ -192,6 +212,10 @@ watch(
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
}
if (props.parentRequirement?.expectedTime) {
model.value.expectedTime = props.parentRequirement.expectedTime;
}
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
@@ -227,14 +251,17 @@ watch(
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
<ElRadioGroup v-model="model.reviewRequired">
<ElRadio
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
border
style="width: 165px"
>
{{ item.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
@@ -243,17 +270,6 @@ watch(
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
@@ -267,9 +283,20 @@ watch(
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
<ElFormItem label="预期完成时间">
<ElDatePicker
v-model="model.expectedTime"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择预期完成时间"
:shortcuts="expectedTimeShortcuts"
class="requirement-operate-dialog__date-picker"
/>
</ElFormItem>
<!-- <ElFormItem label="排序值">-->
<!-- <ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />-->
<!-- </ElFormItem>-->
</BusinessFormSection>
</div>
@@ -330,4 +357,8 @@ watch(
grid-template-columns: 1fr;
}
}
:deep(.requirement-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -3,7 +3,6 @@ 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';
@@ -12,6 +11,7 @@ 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';
import IconMingcuteForward2Line from '~icons/mingcute/forward-2-line';
export type RequirementStatusActionCode =
| 'claim_to_review'
@@ -55,6 +55,7 @@ export const requirementStatusActionRecord: Record<RequirementStatusActionCode,
export const ACTION_ICON_MAP: Record<string, object> = {
split: markRaw(IconTablerSitemap),
edit: markRaw(IconMdiPencilOutline),
forward: markRaw(IconMingcuteForward2Line),
claim_to_review: markRaw(IconMaterialSymbolsDescriptionOutline),
claim_to_dispatch: markRaw(IconMdiCheckOutline),
to_dispatch: markRaw(IconMdiGlasses),
@@ -96,7 +97,7 @@ export function getRequirementStatusTagType(status: Api.Product.RequirementStatu
pending_dispatch: 'primary',
implementing: 'primary',
accepted: 'success',
closed: 'info',
closed: 'danger',
rejected: 'danger',
cancelled: 'danger'
};

View File

@@ -1,20 +1,29 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { OBJECT_CONTEXT_QUERY_KEY, getObjectContextDomainConfigByPath } from '@/constants/object-context';
import { useObjectContextStore } from '@/store/modules/object-context';
import { normalizeCurrentProductSummary, resolveObjectIdFromQuery } from './product-context-shared';
export function useCurrentProduct() {
const route = useRoute();
const objectContextStore = useObjectContextStore();
const isProductDomainRoute = computed(() => getObjectContextDomainConfigByPath(route.path)?.domainKey === 'product');
const currentObjectId = computed(() => {
if (!isProductDomainRoute.value) {
return '';
}
return resolveObjectIdFromQuery(route.query[OBJECT_CONTEXT_QUERY_KEY], objectContextStore.objectId);
});
const currentProduct = computed(() =>
normalizeCurrentProductSummary(objectContextStore.objectSummary, objectContextStore.objectName)
);
const currentProduct = computed(() => {
if (!isProductDomainRoute.value) {
return null;
}
return normalizeCurrentProductSummary(objectContextStore.objectSummary, objectContextStore.objectName);
});
return {
currentObjectId,