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,9 +1,912 @@
<script setup lang="ts">
<script setup lang="tsx">
import { computed, reactive, ref, watch } from 'vue';
import { ElButton, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs';
import {
RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE,
RDMS_REQ_CATEGORY_DICT_CODE,
RDMS_REQ_PRIORITY_DICT_CODE,
RDMS_REQ_SOURCE_TYPE_DICT_CODE
} from '@/constants/dict';
import {
fetchChangeProjectRequirementStatus,
fetchDeleteProjectRequirement,
fetchGetProjectMembers,
fetchGetProjectRequirementAllowedTransitions,
fetchGetProjectRequirementStatusDict,
fetchGetProjectRequirementTerminalStatusDict,
fetchGetProjectRequirementTree
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import { useDict } from '@/hooks/business/dict';
import DictTag from '@/components/custom/dict-tag.vue';
import DictText from '@/components/custom/dict-text.vue';
import { useCurrentProject } from '../../shared/use-current-project';
import {
DEFAULT_ACTION_ICON,
getProjectRequirementActionButtonType,
getProjectRequirementActionDisplayName,
getProjectRequirementActionIcon,
getProjectRequirementStatusTagType,
isProjectRequirementActionTerminal
} from './shared/requirement-master-data';
import RequirementActionDialog from './modules/requirement-action-dialog.vue';
import RequirementCreateDialog from './modules/requirement-create-dialog.vue';
import RequirementDetailDialog from './modules/requirement-detail-dialog.vue';
import RequirementModuleTree from './modules/requirement-module-tree.vue';
import RequirementSearch from './modules/requirement-search.vue';
import RequirementSplitDialog from './modules/requirement-split-dialog.vue';
defineOptions({ name: 'ProjectRequirement' });
interface MemberUserOption {
id: string;
nickname: string;
roleName: string;
}
function formatDateTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
function createSearchParams(): Api.Project.ProjectRequirementSearchParams {
return {
projectId: '',
pageNo: 1,
pageSize: 10,
title: '',
moduleId: undefined,
parentId: undefined,
category: undefined,
priority: undefined,
statusCode: undefined,
currentHandlerUserId: undefined,
sourceType: undefined
};
}
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
0: 'info',
1: 'primary',
2: 'warning',
3: 'danger'
};
const { currentObjectId } = useCurrentProject();
const { hasObjectAuth } = useAuth();
const { hasValue: canDeleteStatusHasValue } = useDict(RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE);
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
const terminalStatusOptions = ref<string[]>([]);
const memberOptions = ref<Api.Project.ProjectMember[]>([]);
const treeData = ref<Api.Project.ProjectRequirement[]>([]);
const loading = ref(false);
const selectedModuleId = ref<string | undefined>('');
const searchParams = reactive(createSearchParams());
const pagination = reactive({
pageNo: 1,
pageSize: 10,
total: 0
});
const createVisible = ref(false);
const detailVisible = ref(false);
const detailMode = ref<'view' | 'edit'>('view');
const selectedRequirement = ref<Api.Project.ProjectRequirement | null>(null);
const splitVisible = ref(false);
const splitParentRequirement = ref<Api.Project.ProjectRequirement | null>(null);
const actionVisible = ref(false);
const actionRequirement = ref<Api.Project.ProjectRequirement | null>(null);
const currentAction = ref<Api.Project.ProjectRequirementLifecycleAction | null>(null);
const allowedTransitionsMap = ref<Map<string, Api.Project.ProjectRequirementLifecycleAction[]>>(new Map());
const columnChecks = ref<UI.TableColumnCheck[]>([]);
const memberUserOptions = computed<MemberUserOption[]>(() => {
return memberOptions.value
.filter(item => item.status === 0)
.map(item => ({
id: item.userId,
nickname: item.userNickname,
roleName: item.roleName
}));
});
const memberLabelMap = computed(() => {
return new Map(memberUserOptions.value.map(item => [item.id, item.nickname]));
});
function getMemberLabel(userId?: string | null) {
if (!userId) {
return '--';
}
return memberLabelMap.value.get(userId) || userId;
}
function getStatusLabel(statusCode: string) {
const item = statusOptions.value.find(option => option.value === statusCode);
return item ? item.label : statusCode;
}
function getPriorityTagType(priority?: number | null): UI.ThemeColor {
if (priority === null || priority === undefined) {
return 'info';
}
return priorityTagTypeMap[priority] || 'info';
}
function isTerminalStatus(statusCode: string) {
return terminalStatusOptions.value.includes(statusCode);
}
function canSplitRequirement(row: Api.Project.ProjectRequirement) {
return row.statusCode === 'implementing';
}
function canDeleteRequirement(row: Api.Project.ProjectRequirement) {
const isStatusAllowed = canDeleteStatusHasValue(row.statusCode);
const hasNoChildren = !row.children || row.children.length === 0;
return isStatusAllowed && hasNoChildren;
}
function flattenTree(nodes: Api.Project.ProjectRequirement[]): Api.Project.ProjectRequirement[] {
const result: Api.Project.ProjectRequirement[] = [];
for (const node of nodes) {
result.push(node);
if (node.children?.length) {
result.push(...flattenTree(node.children));
}
}
return result;
}
function collectAllRequirementIds(nodes: Api.Project.ProjectRequirement[]): 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.Project.ProjectRequirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
if (!isTerminalStatus(node.statusCode)) {
ids.push(node.id);
}
if (node.children?.length) {
ids.push(...collectRequirementIdsForActions(node.children));
}
}
return ids;
}
function getRowActions(row: Api.Project.ProjectRequirement) {
return allowedTransitionsMap.value.get(row.id) || [];
}
function buildRequirementActions(row: Api.Project.ProjectRequirement) {
const actions: Array<{
key: string;
label: string;
icon: object;
type: 'primary' | 'success' | 'danger';
onClick: () => void;
}> = [];
const hasUpdateAuth = hasObjectAuth('project:project:update');
const hasDeleteAuth = hasObjectAuth('project:project:delete');
const hasStatusAuth = hasObjectAuth('project:project:status');
const hasSplitAuth = hasObjectAuth('project:project:split');
if (canSplitRequirement(row) && hasSplitAuth) {
actions.push({
key: 'split',
label: '拆分',
icon: getProjectRequirementActionIcon('split'),
type: getProjectRequirementActionButtonType('split'),
onClick: () => openSplit(row)
});
}
if (hasUpdateAuth && !isTerminalStatus(row.statusCode) && row.statusCode !== 'accepted') {
actions.push({
key: 'edit',
label: '编辑',
icon: getProjectRequirementActionIcon('edit'),
type: getProjectRequirementActionButtonType('edit'),
onClick: () => openEdit(row)
});
}
if (hasStatusAuth) {
const lifecycleActions = getRowActions(row);
const nonTerminalActions: Api.Project.ProjectRequirementLifecycleAction[] = [];
const terminalActions: Api.Project.ProjectRequirementLifecycleAction[] = [];
lifecycleActions.forEach(action => {
if (isProjectRequirementActionTerminal(action.actionCode)) {
terminalActions.push(action);
} else {
nonTerminalActions.push(action);
}
});
[...nonTerminalActions, ...terminalActions].forEach(action => {
actions.push({
key: `action-${action.actionCode}`,
label: getProjectRequirementActionDisplayName(action),
icon: getProjectRequirementActionIcon(action.actionCode) ?? DEFAULT_ACTION_ICON,
type: getProjectRequirementActionButtonType(action.actionCode),
onClick: () => handleActionClick(row, action)
});
});
}
if (hasDeleteAuth && canDeleteRequirement(row)) {
actions.push({
key: 'delete',
label: '删除',
icon: getProjectRequirementActionIcon('delete'),
type: getProjectRequirementActionButtonType('delete'),
onClick: () => handleDelete(row)
});
}
return actions;
}
const columns = computed(() => [
{
type: 'index',
label: '序号',
width: 64,
align: 'center',
index: (index: number): number => {
const flatList = flattenTree(treeData.value);
const row = flatList[index];
if (!row || row.parentId !== '0') {
return 0;
}
const parentIndex = treeData.value.findIndex(item => item.id === row.id);
return parentIndex >= 0 ? (pagination.pageNo - 1) * pagination.pageSize + parentIndex + 1 : 0;
}
},
{
prop: 'title',
label: '需求名称',
minWidth: 220,
formatter: (row: Api.Project.ProjectRequirement) => (
<ElButton link type="primary" class="requirement-title" onClick={() => openView(row)}>
{row.title}
</ElButton>
)
},
{
prop: 'priority',
label: '优先级',
width: 88,
align: 'center',
formatter: (row: Api.Project.ProjectRequirement) => (
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
)
},
{
prop: 'statusCode',
label: '状态',
width: 110,
align: 'center',
formatter: (row: Api.Project.ProjectRequirement) => (
<ElTag type={getProjectRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'workHours',
label: '工时',
width: 88,
align: 'center',
formatter: (row: Api.Project.ProjectRequirement) => (row.workHours !== null ? `${row.workHours}h` : '--')
},
{
prop: 'category',
label: '需求类型',
minWidth: 120,
formatter: (row: Api.Project.ProjectRequirement) => row.categoryName || row.category || '--'
},
{
prop: 'sourceType',
label: '需求来源',
minWidth: 110,
align: 'center',
formatter: (row: Api.Project.ProjectRequirement) => (
<DictText dictCode={RDMS_REQ_SOURCE_TYPE_DICT_CODE} value={row.sourceType} />
)
},
{
prop: 'proposerNickname',
label: '提出人',
minWidth: 90,
formatter: (row: Api.Project.ProjectRequirement) => row.proposerNickname || '--'
},
{
prop: 'currentHandlerUserId',
label: '负责人',
minWidth: 90,
formatter: (row: Api.Project.ProjectRequirement) => getMemberLabel(row.currentHandlerUserId)
},
{
prop: 'sourceBizId',
label: '来源业务编号',
minWidth: 140,
formatter: (row: Api.Project.ProjectRequirement) => {
if (!row.sourceBizId || row.sourceType === 'manual') {
return '--';
}
return row.sourceBizId;
}
},
{
prop: 'createTime',
label: '创建时间',
minWidth: 180,
formatter: (row: Api.Project.ProjectRequirement) => formatDateTime(row.createTime)
},
{
prop: 'operate',
label: '操作',
width: 200,
align: 'center',
fixed: 'right',
formatter: (row: Api.Project.ProjectRequirement) => (
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
{buildRequirementActions(row).map(action => {
const IconComponent = action.icon as any;
return (
<ElTooltip key={action.key} content={action.label} placement="top">
<ElButton
link
size="small"
class="requirement-action-icon-btn"
type={action.type}
onClick={() => action.onClick()}
>
<IconComponent class="text-18px" />
</ElButton>
</ElTooltip>
);
})}
</div>
)
}
]);
watch(
() => columns.value,
value => {
const existingMap = new Map(columnChecks.value.map(item => [item.prop, item.checked]));
columnChecks.value = value
.filter(column => column.prop && column.prop !== 'operate')
.map(column => ({
prop: String(column.prop),
label: String(column.label || ''),
checked: existingMap.has(String(column.prop)) ? existingMap.get(String(column.prop))! : true,
visible: true
}));
},
{ immediate: true }
);
const visibleColumns = computed(() => {
if (!columnChecks.value.length) {
return columns.value;
}
const visibleSet = new Set(columnChecks.value.filter(item => item.checked).map(item => item.prop));
return columns.value.filter(column => {
const prop = String(column.prop || '');
if (!prop || prop === 'operate') {
return true;
}
return visibleSet.has(prop);
});
});
async function loadStatusOptions() {
const { error, data } = await fetchGetProjectRequirementStatusDict();
if (error || !data) {
statusOptions.value = [];
return;
}
statusOptions.value = data.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
async function loadTerminalStatusOptions() {
const { error, data } = await fetchGetProjectRequirementTerminalStatusDict();
if (error || !data) {
terminalStatusOptions.value = [];
return;
}
terminalStatusOptions.value = data.map(item => item.statusCode);
}
async function loadMembers() {
if (!currentObjectId.value) {
memberOptions.value = [];
return;
}
const { error, data } = await fetchGetProjectMembers(currentObjectId.value);
if (error || !data) {
memberOptions.value = [];
return;
}
memberOptions.value = data;
}
async function loadTreeData() {
if (!currentObjectId.value) {
treeData.value = [];
pagination.total = 0;
return;
}
const { error, data } = await fetchGetProjectRequirementTree({
projectId: currentObjectId.value,
moduleId: selectedModuleId.value,
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
title: searchParams.title?.trim() || undefined,
category: searchParams.category,
priority: searchParams.priority,
statusCode: searchParams.statusCode,
currentHandlerUserId: searchParams.currentHandlerUserId,
sourceType: searchParams.sourceType
});
if (error || !data) {
treeData.value = [];
pagination.total = 0;
return;
}
treeData.value = data.list;
pagination.total = data.total;
}
async function loadAllowedTransitionsForAll() {
if (!currentObjectId.value) {
allowedTransitionsMap.value = new Map();
return;
}
const idsToQuery = collectRequirementIdsForActions(treeData.value);
const nextMap = new Map<string, Api.Project.ProjectRequirementLifecycleAction[]>();
if (idsToQuery.length === 0) {
allowedTransitionsMap.value = nextMap;
return;
}
const results = await Promise.all(
idsToQuery.map(async id => {
const { error, data } = await fetchGetProjectRequirementAllowedTransitions(id, currentObjectId.value!);
return { id, actions: error ? [] : data || [] };
})
);
results.forEach(({ id, actions }) => {
nextMap.set(id, actions);
});
allowedTransitionsMap.value = nextMap;
}
async function reloadTable() {
loading.value = true;
try {
await loadTreeData();
await loadAllowedTransitionsForAll();
} finally {
loading.value = false;
}
}
function handleModuleSelect(moduleId: string | undefined) {
selectedModuleId.value = moduleId;
pagination.pageNo = 1;
reloadTable();
}
function handleSearch() {
pagination.pageNo = 1;
reloadTable();
}
function handleResetSearch() {
Object.assign(searchParams, createSearchParams(), {
projectId: currentObjectId.value || '',
pageSize: pagination.pageSize
});
pagination.pageNo = 1;
reloadTable();
}
function handlePageChange(page: number) {
pagination.pageNo = page;
reloadTable();
}
function handleSizeChange(size: number) {
pagination.pageNo = 1;
pagination.pageSize = size;
reloadTable();
}
function openCreate() {
selectedRequirement.value = null;
createVisible.value = true;
}
function openView(row: Api.Project.ProjectRequirement) {
selectedRequirement.value = row;
detailMode.value = 'view';
detailVisible.value = true;
}
function openEdit(row: Api.Project.ProjectRequirement) {
selectedRequirement.value = row;
detailMode.value = 'edit';
detailVisible.value = true;
}
function openSplit(row: Api.Project.ProjectRequirement) {
splitParentRequirement.value = row;
splitVisible.value = true;
}
function handleActionClick(row: Api.Project.ProjectRequirement, action: Api.Project.ProjectRequirementLifecycleAction) {
if (!action.needReason) {
handleDirectAction(row, action);
return;
}
actionRequirement.value = row;
currentAction.value = action;
actionVisible.value = true;
}
async function handleDirectAction(
row: Api.Project.ProjectRequirement,
action: Api.Project.ProjectRequirementLifecycleAction
) {
if (!currentObjectId.value) {
return;
}
try {
await window.$messageBox?.confirm(`确定要执行“${action.actionName}”操作吗?`, '确认操作', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'info'
});
} catch {
return;
}
const { error } = await fetchChangeProjectRequirementStatus({
id: row.id,
projectId: currentObjectId.value,
actionCode: action.actionCode
});
if (error) {
return;
}
window.$message?.success(`${action.actionName}成功`);
await reloadTable();
}
async function handleActionSubmitted(payload: { actionCode: string; reason?: string }) {
if (!currentObjectId.value || !actionRequirement.value) {
return;
}
const { error } = await fetchChangeProjectRequirementStatus({
id: actionRequirement.value.id,
projectId: currentObjectId.value,
actionCode: payload.actionCode,
reason: payload.reason
});
if (error) {
return;
}
window.$message?.success('操作成功');
actionVisible.value = false;
await reloadTable();
}
async function handleDelete(row: Api.Project.ProjectRequirement) {
if (!currentObjectId.value) {
return;
}
try {
await window.$messageBox?.confirm('确定要删除该需求吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
const { error } = await fetchDeleteProjectRequirement({
id: row.id,
projectId: currentObjectId.value
});
if (error) {
return;
}
window.$message?.success('需求删除成功');
await reloadTable();
}
async function handleCreateSubmitted() {
createVisible.value = false;
await reloadTable();
}
async function handleDetailSubmitted() {
detailVisible.value = false;
await reloadTable();
}
async function handleSplitSubmitted() {
splitVisible.value = false;
await reloadTable();
}
watch(
() => currentObjectId.value,
async id => {
Object.assign(searchParams, createSearchParams(), {
projectId: id || '',
pageSize: pagination.pageSize
});
selectedModuleId.value = '';
pagination.pageNo = 1;
if (!id) {
memberOptions.value = [];
treeData.value = [];
allowedTransitionsMap.value = new Map();
pagination.total = 0;
return;
}
await Promise.all([loadMembers(), loadTreeData()]);
await loadAllowedTransitionsForAll();
},
{ immediate: true }
);
Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
</script>
<template>
<div class="p-16px">
<ElEmpty description="需求池功能开发中..." />
<div v-if="currentObjectId" class="project-requirement-page">
<div class="flex-col-stretch gap-16px xl:min-h-0">
<RequirementModuleTree :requirement-tree="treeData" @select="handleModuleSelect" @refresh="reloadTable" />
</div>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<RequirementSearch
v-model:model="searchParams"
:member-options="memberUserOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@reset="handleResetSearch"
@search="handleSearch"
/>
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="requirement-table-card-body">
<template #header>
<div class="project-requirement-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">{{ pagination.total }}</ElTag>
</div>
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton
v-auth="{ code: 'project:project:create', source: 'object' }"
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"
border
lazy
row-key="id"
:indent="32"
height="100%"
:data="treeData"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<ElTableColumn v-for="column in visibleColumns" :key="String(column.prop || 'index')" v-bind="column" />
<template #empty>
<ElEmpty description="当前筛选条件下暂无项目需求" />
</template>
</ElTable>
</div>
<div class="mt-16px flex justify-end">
<ElPagination
v-model:current-page="pagination.pageNo"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 15, 20, 25, 30]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElCard>
</div>
<RequirementCreateDialog
v-model:visible="createVisible"
:project-id="currentObjectId || ''"
:default-module-id="selectedModuleId"
:member-options="memberOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@submitted="handleCreateSubmitted"
/>
<RequirementDetailDialog
v-model:visible="detailVisible"
:mode="detailMode"
:requirement="selectedRequirement"
:project-id="currentObjectId || ''"
:member-options="memberOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@submitted="handleDetailSubmitted"
/>
<RequirementSplitDialog
v-model:visible="splitVisible"
:parent-requirement="splitParentRequirement"
:project-id="currentObjectId || ''"
:member-options="memberOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@submitted="handleSplitSubmitted"
/>
<RequirementActionDialog
v-model:visible="actionVisible"
:action="currentAction"
:requirement-title="actionRequirement?.title || ''"
@submitted="handleActionSubmitted"
/>
</div>
<ElEmpty v-else description="未获取到当前项目上下文,请返回项目列表重新选择项目" />
</template>
<style lang="scss" scoped>
.project-requirement-page {
display: grid;
min-height: 560px;
gap: 16px;
overflow: hidden;
grid-template-columns: 280px minmax(0, 1fr);
}
.project-requirement-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
:deep(.requirement-title) {
padding: 0;
font-weight: 500;
}
:deep(.requirement-table-card-body) {
height: calc(100% - 56px);
display: flex;
flex-direction: column;
}
:deep(.requirement-action-cell) {
display: inline-flex;
align-items: center;
gap: 1px;
}
:deep(.requirement-action-icon-btn) {
padding: 1px;
height: auto;
min-width: auto;
}
:deep(.requirement-action-icon-btn:hover) {
background-color: var(--el-fill-color-light);
}
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
color: transparent;
}
@media (width <= 1280px) {
.project-requirement-page {
display: flex;
flex-direction: column;
overflow: auto;
}
.project-requirement-card-header {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ElTag } from 'element-plus';
defineOptions({ name: 'ProjectRequirementMemberSelectOption' });
interface Props {
nickname: string;
roleName: string;
}
defineProps<Props>();
</script>
<template>
<div class="member-select-option">
<span class="member-select-option__name">{{ nickname }}</span>
<ElTag type="info" size="small" class="member-select-option__role">{{ roleName }}</ElTag>
</div>
</template>
<style scoped>
.member-select-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.member-select-option__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-select-option__role {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,332 @@
<script setup lang="ts">
import { type Ref, computed, inject, ref } from 'vue';
defineOptions({ name: 'ProjectRequirementModuleTreeNode' });
interface Props {
module: Api.Project.ProjectRequirementModule;
level?: number;
selectedModuleId?: string;
editingNodeId?: string;
editingName?: string;
addingChildParentId?: string;
newChildModuleName?: string;
rootModuleId?: string;
moduleRequirementCountMap?: Map<string, number>;
}
const props = withDefaults(defineProps<Props>(), {
level: 0
});
const emit = defineEmits([
'select',
'edit',
'editConfirm',
'editCancel',
'delete',
'addChild',
'addChildConfirm',
'addChildCancel',
'updateEditingName',
'updateNewChildModuleName'
]);
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);
const hasChildren = computed(() => Boolean(props.module.children?.length));
const isCollapsed = computed(() =>
hasChildren.value && props.module.id ? collapsedModuleIds.value.has(props.module.id) : false
);
const hasRequirements = computed(() => {
const moduleId = props.module.id;
if (!moduleId || !props.moduleRequirementCountMap) {
return false;
}
return (props.moduleRequirementCountMap.get(moduleId) || 0) > 0;
});
const canDeleteModule = computed(() => !hasChildren.value && !hasRequirements.value);
const indentStyle = computed(() => {
if (props.level === 0) {
return {};
}
const indent = 24 + (props.level - 1) * 24;
return {
width: `calc(100% - ${indent}px)`,
marginLeft: `${indent}px`
};
});
function handleClick() {
if (props.editingNodeId || props.addingChildParentId) {
return;
}
emit('select', props.module.id);
}
function handleToggle() {
if (props.module.id) {
toggleCollapse(props.module.id);
}
}
</script>
<template>
<div class="module-tree-node">
<div
class="module-tree-item"
:class="{
'is-root': isRootModule,
'is-active': isSelected,
'is-editing': isEditing
}"
:style="indentStyle"
@click="handleClick"
>
<div
class="module-tree-item__toggle"
:class="{ 'is-expanded': hasChildren && !isCollapsed }"
@click.stop="handleToggle"
>
<icon-ic-round-chevron-right v-if="hasChildren" class="text-14px" />
</div>
<div class="module-tree-item__icon">
<icon-mdi-folder-open v-if="isRootModule" class="text-16px" />
<icon-mdi-folder-outline v-else class="text-16px" />
</div>
<div class="module-tree-item__content">
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
<ElInput
v-else
:model-value="editingName"
size="small"
class="module-tree-item__input"
placeholder="请输入模块名"
@update:model-value="emit('updateEditingName', $event)"
@blur="emit('editConfirm', module)"
@keyup.enter="emit('editConfirm', module)"
@keyup.esc="emit('editCancel')"
/>
</div>
<div v-if="!isEditing" 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:project:create', source: 'object' }"
@click="emit('addChild', module)"
>
<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:project:update', source: 'object' }"
@click="emit('edit', module)"
>
<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:project:delete', source: 'object' }"
divided
@click="emit('delete', module)"
>
<div class="flex items-center gap-6px text-error">
<icon-mdi-delete-outline class="text-14px" />
<span>删除</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
<template v-if="hasChildren && !isCollapsed">
<ModuleTreeNode
v-for="child in module.children"
:key="child.id"
:module="child"
:level="level + 1"
:selected-module-id="selectedModuleId"
:editing-node-id="editingNodeId"
:editing-name="editingName"
:adding-child-parent-id="addingChildParentId"
:new-child-module-name="newChildModuleName"
:root-module-id="rootModuleId"
:module-requirement-count-map="moduleRequirementCountMap"
@select="emit('select', $event)"
@edit="emit('edit', $event)"
@edit-confirm="emit('editConfirm', $event)"
@edit-cancel="emit('editCancel')"
@delete="emit('delete', $event)"
@add-child="emit('addChild', $event)"
@add-child-confirm="emit('addChildConfirm')"
@add-child-cancel="emit('addChildCancel')"
@update-editing-name="emit('updateEditingName', $event)"
@update-new-child-module-name="emit('updateNewChildModuleName', $event)"
/>
</template>
<div
v-if="isAddingChild"
class="module-tree-item module-tree-item--new"
:style="{
width: indentStyle.width,
marginLeft: level === 0 ? '24px' : `calc(24px + ${level * 24}px)`
}"
>
<div class="module-tree-item__icon">
<icon-mdi-folder-plus-outline class="text-16px" />
</div>
<div class="module-tree-item__content">
<ElInput
:model-value="newChildModuleName"
size="small"
class="new-child-module-input module-tree-item__input"
placeholder="请输入模块名"
@update:model-value="emit('updateNewChildModuleName', $event)"
@blur="emit('addChildConfirm')"
@keyup.enter="emit('addChildConfirm')"
@keyup.esc="emit('addChildCancel')"
/>
</div>
</div>
</div>
</template>
<style scoped>
.module-tree-node {
display: flex;
flex-direction: column;
gap: 10px;
}
.module-tree-item {
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%);
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-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
}
.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;
}
.module-tree-item.is-root:not(.is-active) .module-tree-item__icon {
color: rgb(13 148 136 / 80%);
}
.module-tree-item--new {
border-style: dashed;
border-color: rgb(148 163 184 / 56%);
}
.module-tree-item__icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: rgb(100 116 139 / 80%);
}
.module-tree-item__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
cursor: pointer;
user-select: none;
transition: transform 0.2s ease;
color: rgb(148 163 184);
}
.module-tree-item__toggle.is-expanded svg {
transform: rotate(90deg);
}
.module-tree-item__content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.module-tree-item__label {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-tree-item__input {
width: 100%;
}
.module-tree-item__input :deep(.el-input__inner) {
height: 28px;
}
.module-tree-item__actions {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.module-tree-item:hover .module-tree-item__actions {
opacity: 1;
}
.module-tree-item.is-editing .module-tree-item__actions {
opacity: 0;
}
.module-tree-item__more-btn {
padding: 4px;
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProjectRequirementActionDialog' });
interface Props {
action: Api.Project.ProjectRequirementLifecycleAction | null;
requirementTitle: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', payload: { actionCode: string; reason?: string }): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = ref({
reason: ''
});
const submitting = ref(false);
const dialogTitle = computed(() => props.action?.actionName || '');
const needReason = computed(() => Boolean(props.action?.needReason));
const rules = computed(() => ({
reason: needReason.value ? [createRequiredRule('请输入状态变更原因')] : []
}));
watch(
() => visible.value,
value => {
if (value) {
model.value.reason = '';
}
}
);
async function handleSubmit() {
await validate();
if (!props.action) {
return;
}
submitting.value = true;
emit('submitted', {
actionCode: props.action.actionCode,
reason: needReason.value ? model.value.reason.trim() : undefined
});
submitting.value = false;
visible.value = false;
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="sm"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<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="needReason" label="变更原因" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入状态变更原因(必填)"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,303 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { fetchCreateProjectRequirement, fetchGetProjectRequirementModuleTree } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'ProjectRequirementCreateDialog' });
interface Props {
projectId: string;
defaultModuleId?: string;
memberOptions: Api.Project.ProjectMember[];
categoryDictCode: string;
priorityDictCode: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
value: Number(item.value)
}));
});
interface Model {
title: string;
description: string;
reviewRequired: number;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
currentHandlerUserId: string;
workHours: number | null;
sort: number;
}
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
const submitting = ref(false);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
const options: Array<{ label: string; value: string }> = [];
function walk(modules: Api.Project.ProjectRequirementModule[], parentPath: string) {
for (const module of modules) {
const currentPath = `${parentPath}/${module.moduleName}`;
options.push({
label: currentPath,
value: module.id || ''
});
if (module.children?.length) {
walk(module.children, currentPath);
}
}
}
if (moduleTree.value.length) {
walk(moduleTree.value, '');
}
return options;
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入需求名称')],
category: [createRequiredRule('请选择需求类型')],
priority: [createRequiredRule('请选择优先级')],
proposerId: [createRequiredRule('请选择提出人')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
moduleId: props.defaultModuleId || '0',
category: '功能需求',
priority: 1,
proposerId: '',
currentHandlerUserId: '',
workHours: null,
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
async function loadModuleTree() {
if (!props.projectId) {
moduleTree.value = [];
return;
}
const { error, data } = await fetchGetProjectRequirementModuleTree(props.projectId);
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
}
async function handleSubmit() {
await validate();
if (!props.projectId) {
return;
}
const proposer = memberUserOptions.value.find(item => item.userId === model.value.proposerId);
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
const payload: Api.Project.SaveProjectRequirementParams = {
projectId: props.projectId,
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
category: model.value.category,
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
proposerId: model.value.proposerId,
proposerNickname: proposer?.userNickname || '',
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: handler?.userNickname || '',
workHours: model.value.workHours || 0,
sort: model.value.sort
};
submitting.value = true;
const result = await fetchCreateProjectRequirement(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('项目需求新增成功');
visible.value = false;
emit('submitted');
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.value = createDefaultModel();
await loadModuleTree();
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="新增项目需求"
preset="lg"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<BusinessRichTextEditor
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="需求类型" prop="category">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择需求类型"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,390 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import {
fetchGetProjectRequirement,
fetchGetProjectRequirementModuleTree,
fetchUpdateProjectRequirement
} from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import ReadonlyField from '@/components/custom/readonly-field.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'ProjectRequirementDetailDialog' });
type DialogMode = 'view' | 'edit';
interface Props {
mode: DialogMode;
requirement: Api.Project.ProjectRequirement | null;
projectId: string;
memberOptions: Api.Project.ProjectMember[];
categoryDictCode: string;
priorityDictCode: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', requirementId?: string): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
value: Number(item.value)
}));
});
interface Model {
title: string;
description: string;
reviewRequired: number;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
proposerNickname: string;
currentHandlerUserId: string;
currentHandlerUserNickname: string;
workHours: number | null;
sort: number;
lastStatusReason: string;
}
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
const loading = ref(false);
const submitting = ref(false);
const model = ref<Model>(createDefaultModel());
const isViewMode = computed(() => props.mode === 'view');
const isEditMode = computed(() => props.mode === 'edit');
const dialogTitle = computed(() => (isViewMode.value ? '查看项目需求' : '编辑项目需求'));
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
const memberLabelMap = computed(() => {
return new Map(memberUserOptions.value.map(item => [item.userId, item.userNickname]));
});
const moduleLabelMap = computed(() => {
const map = new Map<string | undefined, string>();
function walk(modules: Api.Project.ProjectRequirementModule[]) {
for (const module of modules) {
map.set(module.id, module.moduleName);
if (module.children?.length) {
walk(module.children);
}
}
}
walk(moduleTree.value);
return map;
});
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
const options: Array<{ label: string; value: string }> = [];
function walk(modules: Api.Project.ProjectRequirementModule[], parentPath: string) {
for (const module of modules) {
const currentPath = `${parentPath}/${module.moduleName}`;
options.push({
label: currentPath,
value: module.id || ''
});
if (module.children?.length) {
walk(module.children, currentPath);
}
}
}
if (moduleTree.value.length) {
walk(moduleTree.value, '');
}
return options;
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = computed(() => ({
title: isEditMode.value ? [createRequiredRule('请输入需求名称')] : [],
category: isEditMode.value ? [createRequiredRule('请选择需求类型')] : [],
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
}));
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
moduleId: '0',
category: '',
priority: 1,
proposerId: '',
proposerNickname: '',
currentHandlerUserId: '',
currentHandlerUserNickname: '',
workHours: null,
sort: 0,
lastStatusReason: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
async function loadModuleTree() {
if (!props.projectId) {
moduleTree.value = [];
return;
}
const { error, data } = await fetchGetProjectRequirementModuleTree(props.projectId);
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
}
async function loadRequirementDetail() {
if (!props.projectId || !props.requirement?.id) {
return;
}
loading.value = true;
const { error, data } = await fetchGetProjectRequirement(props.requirement.id, props.projectId);
loading.value = false;
if (error || !data) {
return;
}
model.value = {
title: data.title || '',
description: data.description || '',
reviewRequired: data.reviewRequired ?? 0,
moduleId: data.moduleId || '0',
category: data.category || '',
priority: data.priority ?? null,
proposerId: data.proposerId || '',
proposerNickname: data.proposerNickname || '',
currentHandlerUserId: data.currentHandlerUserId || '',
currentHandlerUserNickname: data.currentHandlerUserNickname || '',
workHours: data.workHours ?? null,
sort: data.sort ?? 0,
lastStatusReason: data.lastStatusReason || ''
};
}
async function handleSubmit() {
await validate();
if (!props.projectId || !props.requirement?.id) {
return;
}
submitting.value = true;
const payload: Api.Project.UpdateProjectRequirementParams = {
id: props.requirement.id,
projectId: props.projectId,
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
category: model.value.category,
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
proposerId: model.value.proposerId,
proposerNickname: model.value.proposerNickname,
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
const { error } = await fetchUpdateProjectRequirement(payload);
submitting.value = false;
if (error) {
return;
}
window.$message?.success('项目需求更新成功');
visible.value = false;
emit('submitted', props.requirement.id);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
await loadModuleTree();
if (props.requirement?.id) {
await loadRequirementDetail();
}
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
:loading="loading"
:confirm-loading="submitting"
:show-footer="isEditMode"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="需求名称" prop="title">
<ReadonlyField v-if="isViewMode" :value="model.title" />
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<ReadonlyField v-if="isViewMode" :value="moduleLabelMap.get(model.moduleId) || '--'" />
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<div v-if="isViewMode" class="readonly-textarea" v-html="model.description || '--'"></div>
<BusinessRichTextEditor
v-else
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ReadonlyField
:value="reviewRequiredOptions.find(item => item.value === model.reviewRequired)?.label || '--'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ReadonlyField
v-if="isViewMode"
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
/>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时">
<ReadonlyField v-if="isViewMode" :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="需求类型" prop="category">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ReadonlyField v-if="isViewMode" :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
<ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ReadonlyField v-if="isViewMode" :value="model.sort" />
<ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
<ElCol v-if="isViewMode && model.lastStatusReason" :span="24">
<ElFormItem label="状态变更原因">
<div class="readonly-textarea">{{ model.lastStatusReason }}</div>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.readonly-textarea {
box-sizing: border-box;
width: 100%;
min-height: 100px;
padding: 8px 12px;
border-radius: 4px;
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;
color: rgb(51 65 85 / 96%);
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,312 @@
<script setup lang="ts">
import { computed, nextTick, provide, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
fetchCreateProjectRequirementModule,
fetchDeleteProjectRequirementModule,
fetchGetProjectRequirementModuleTree,
fetchUpdateProjectRequirementModule
} from '@/service/api';
import { useCurrentProject } from '../../../shared/use-current-project';
import ModuleTreeNode from './module-tree-node.vue';
defineOptions({ name: 'ProjectRequirementModuleTree' });
interface Props {
requirementTree?: Api.Project.ProjectRequirement[];
}
const props = withDefaults(defineProps<Props>(), {
requirementTree: () => []
});
interface Emits {
(e: 'select', moduleId: string | undefined): void;
(e: 'refresh'): void;
}
const emit = defineEmits<Emits>();
const { currentObjectId } = useCurrentProject();
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
const selectedModuleId = ref<string | undefined>(undefined);
const editingNodeId = ref<string | undefined>(undefined);
const editingName = ref('');
const addingChildParentId = ref<string | undefined>(undefined);
const newChildModuleName = ref('');
const collapsedModuleIds = ref(new Set<string>());
provide('collapsedModuleIds', collapsedModuleIds);
provide('toggleCollapse', (moduleId: string) => {
const nextSet = new Set(collapsedModuleIds.value);
if (nextSet.has(moduleId)) {
nextSet.delete(moduleId);
} else {
nextSet.add(moduleId);
}
collapsedModuleIds.value = nextSet;
});
const rootModule = computed<Api.Project.ProjectRequirementModule | null>(() => {
return moduleTree.value.length ? moduleTree.value[0] : null;
});
const moduleRequirementCountMap = computed(() => {
const countMap = new Map<string, number>();
function countRequirementsByModule(nodes: Api.Project.ProjectRequirement[]) {
for (const node of nodes) {
countMap.set(node.moduleId, (countMap.get(node.moduleId) || 0) + 1);
if (node.children?.length) {
countRequirementsByModule(node.children);
}
}
}
if (props.requirementTree.length) {
countRequirementsByModule(props.requirementTree);
}
return countMap;
});
async function loadModuleTree() {
if (!currentObjectId.value) {
moduleTree.value = [];
return;
}
const { error, data } = await fetchGetProjectRequirementModuleTree(currentObjectId.value);
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
if (data.length > 0 && !selectedModuleId.value) {
selectedModuleId.value = data[0].id;
emit('select', data[0].id);
}
}
function handleNodeSelect(moduleId: string) {
if (editingNodeId.value || addingChildParentId.value) {
return;
}
selectedModuleId.value = moduleId;
emit('select', moduleId);
}
function handleStartEdit(module: Api.Project.ProjectRequirementModule) {
editingNodeId.value = module.id;
editingName.value = module.moduleName;
nextTick(() => {
const input = document.querySelector('.module-tree-item.is-editing .el-input__inner') as HTMLInputElement | null;
input?.focus();
input?.select();
});
}
async function handleEditConfirm(module: Api.Project.ProjectRequirementModule) {
const moduleName = editingName.value.trim();
editingNodeId.value = undefined;
if (!moduleName || moduleName === module.moduleName || !currentObjectId.value) {
return;
}
const { error } = await fetchUpdateProjectRequirementModule({
id: module.id,
projectId: currentObjectId.value,
parentId: module.parentId,
moduleName,
remark: module.remark,
icon: module.icon,
sort: module.sort
});
if (error) {
return;
}
window.$message?.success('模块名称更新成功');
await loadModuleTree();
emit('refresh');
}
function handleEditCancel() {
editingNodeId.value = undefined;
}
function handleStartAddChild(module: Api.Project.ProjectRequirementModule) {
if (addingChildParentId.value) {
return;
}
addingChildParentId.value = module.id;
newChildModuleName.value = '';
nextTick(() => {
const input = document.querySelector('.new-child-module-input input') as HTMLInputElement | null;
input?.focus();
});
}
async function handleAddChildConfirm() {
const moduleName = newChildModuleName.value.trim();
const parentId = addingChildParentId.value;
addingChildParentId.value = undefined;
newChildModuleName.value = '';
if (!moduleName || !parentId || !currentObjectId.value) {
return;
}
const { error } = await fetchCreateProjectRequirementModule({
projectId: currentObjectId.value,
parentId,
moduleName,
remark: null,
icon: null,
sort: 0
});
if (error) {
return;
}
window.$message?.success('子模块新增成功');
await loadModuleTree();
emit('refresh');
}
function handleAddChildCancel() {
addingChildParentId.value = undefined;
newChildModuleName.value = '';
}
async function handleDeleteModule(module: Api.Project.ProjectRequirementModule) {
if (!currentObjectId.value) {
return;
}
try {
await ElMessageBox.confirm(`确定要删除模块“${module.moduleName}”吗?该模块下的所有需求将一并删除。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
const { error } = await fetchDeleteProjectRequirementModule({
id: module.id,
projectId: currentObjectId.value
});
if (error) {
return;
}
window.$message?.success('模块删除成功');
if (selectedModuleId.value === module.id) {
const rootId = rootModule.value?.id || '';
selectedModuleId.value = rootId;
emit('select', rootId);
}
await loadModuleTree();
emit('refresh');
}
watch(
() => currentObjectId.value,
async id => {
if (!id) {
moduleTree.value = [];
return;
}
selectedModuleId.value = '';
await loadModuleTree();
},
{ immediate: true }
);
defineExpose({
loadModuleTree,
selectedModuleId
});
</script>
<template>
<div class="requirement-module-tree-wrapper">
<div class="module-tree-header">
<span class="module-tree-header__title">模块</span>
</div>
<div class="module-tree-list">
<template v-for="item in moduleTree" :key="item.id">
<ModuleTreeNode
:module="item"
:level="0"
:selected-module-id="selectedModuleId"
:editing-node-id="editingNodeId"
:editing-name="editingName"
:adding-child-parent-id="addingChildParentId"
:new-child-module-name="newChildModuleName"
:root-module-id="rootModule?.id"
:module-requirement-count-map="moduleRequirementCountMap"
@select="handleNodeSelect"
@edit="handleStartEdit"
@edit-confirm="handleEditConfirm"
@edit-cancel="handleEditCancel"
@delete="handleDeleteModule"
@add-child="handleStartAddChild"
@add-child-confirm="handleAddChildConfirm"
@add-child-cancel="handleAddChildCancel"
@update-editing-name="editingName = $event"
@update-new-child-module-name="newChildModuleName = $event"
/>
</template>
</div>
</div>
</template>
<style scoped>
.requirement-module-tree-wrapper {
display: flex;
flex-direction: column;
gap: 14px;
}
.module-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.module-tree-header__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.module-tree-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProjectRequirementStatusDict } 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';
defineOptions({ name: 'ProjectRequirementSearch' });
interface MemberUserOption {
id: string;
nickname: string;
roleName?: string;
}
interface Props {
memberOptions: MemberUserOption[];
categoryDictCode: string;
priorityDictCode: string;
}
defineProps<Props>();
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.Project.ProjectRequirementSearchParams>('model', { required: true });
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
const { enabledDictData: sourceTypeDictData } = useDict(RDMS_REQ_SOURCE_TYPE_DICT_CODE);
const sourceTypeOptions = computed(() => {
return sourceTypeDictData.value.map(item => ({
label: item.label,
value: item.value
}));
});
async function loadStatusOptions() {
const { error, data } = await fetchGetProjectRequirementStatusDict();
if (error || !data) {
requirementStatusOptions.value = [];
return;
}
requirementStatusOptions.value = data.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
function reset() {
emit('reset');
}
function search() {
emit('search');
}
onMounted(async () => {
await loadStatusOptions();
});
</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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { fetchSplitProjectRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'ProjectRequirementSplitDialog' });
interface Props {
parentRequirement: Api.Project.ProjectRequirement | null;
projectId: string;
memberOptions: Api.Project.ProjectMember[];
categoryDictCode: string;
priorityDictCode: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
value: Number(item.value)
}));
});
interface Model {
title: string;
description: string;
reviewRequired: number;
category: string;
priority: number | null;
currentHandlerUserId: string;
workHours: number | null;
sort: number;
}
const model = ref<Model>(createDefaultModel());
const submitting = ref(false);
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入子需求名称')],
priority: [createRequiredRule('请选择优先级')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
category: '',
priority: 1,
currentHandlerUserId: '',
workHours: null,
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
async function handleSubmit() {
await validate();
if (!props.projectId || !props.parentRequirement?.id) {
return;
}
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
const payload: Api.Project.SplitProjectRequirementParams = {
parentId: props.parentRequirement.id,
projectId: props.projectId,
moduleId: props.parentRequirement.moduleId,
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
category: model.value.category,
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
proposerId: props.parentRequirement.proposerId,
proposerNickname: props.parentRequirement.proposerNickname || '',
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: handler?.userNickname || '',
workHours: model.value.workHours || 0,
sort: model.value.sort
};
submitting.value = true;
const result = await fetchSplitProjectRequirement(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('项目需求拆分成功');
visible.value = false;
emit('submitted');
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.value = createDefaultModel();
if (props.parentRequirement?.category) {
model.value.category = props.parentRequirement.category;
}
// 默认选中父需求的负责人
if (props.parentRequirement?.currentHandlerUserId) {
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
}
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="拆分项目需求"
preset="lg"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElAlert
v-if="parentRequirement"
:title="`正在拆分需求:${parentRequirement.title}`"
type="info"
:closable="false"
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="子需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<BusinessRichTextEditor
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,122 @@
import { markRaw } from '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 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';
/**
* 项目需求状态记录
*
* 来源rdms_object_status_model 表 object_type='project_requirement' 的 7 条记录
*/
export const projectRequirementStatusRecord: Record<Api.Project.ProjectRequirementStatusCode, string> = {
pending_confirm: '待确认',
pending_review: '待评审',
implementing: '实施中',
accepted: '已验收',
closed: '已关闭',
rejected: '已拒绝',
cancelled: '已取消'
};
/**
* 终态状态码集合
*
* 来源terminal_flag=1 的状态: closed, rejected, cancelled
*/
const TERMINAL_STATUS_SET = new Set<Api.Project.ProjectRequirementStatusCode>(['closed', 'rejected', 'cancelled']);
/**
* 操作按钮图标映射
*
* 将操作类型映射到对应的 Iconify 图标组件
*/
export const ACTION_ICON_MAP: Record<string, object> = {
split: markRaw(IconTablerSitemap),
edit: markRaw(IconMdiPencilOutline),
claim_to_review: markRaw(IconMdiCheckOutline),
claim_to_implement: markRaw(IconMdiCheckCircleOutline),
pass_review: markRaw(IconMdiGlasses),
accept: markRaw(IconMdiCheckCircleOutline),
reject: markRaw(IconMdiClose),
cancel: markRaw(IconTablerCircleX),
close: markRaw(IconMdiPowerSettingsNew),
delete: markRaw(IconMdiDeleteOutline)
};
export const DEFAULT_ACTION_ICON = markRaw(IconMdiSync);
function resolveActionKeyword(actionCode: string) {
return Object.keys(ACTION_ICON_MAP).find(keyword => actionCode.includes(keyword));
}
/**
* 获取项目需求状态的标签颜色
*/
export function getProjectRequirementStatusTagType(status: Api.Project.ProjectRequirementStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Project.ProjectRequirementStatusCode, UI.ThemeColor> = {
pending_confirm: 'info',
pending_review: 'warning',
implementing: 'primary',
accepted: 'success',
closed: 'info',
rejected: 'danger',
cancelled: 'danger'
};
return statusTagTypeMap[status];
}
/**
* 判断状态是否为终态
*/
export function isProjectRequirementTerminal(statusCode: string) {
return TERMINAL_STATUS_SET.has(statusCode as Api.Project.ProjectRequirementStatusCode);
}
/**
* 判断动作是否为终态动作
*
* 终态动作定义执行后需求进入终态closed/rejected/cancelled
* 包括reject, cancel, close
*/
export function isProjectRequirementActionTerminal(actionCode: string) {
const terminalActions = new Set(['reject', 'cancel', 'close']);
return terminalActions.has(actionCode);
}
/**
* 获取操作按钮的颜色类型
*
* 成功/审批类 → success危险操作 → danger其他 → primary
*/
export function getProjectRequirementActionButtonType(actionCode: string): 'primary' | 'success' | 'danger' {
if (['reject', 'cancel', 'close', 'delete'].includes(actionCode)) {
return 'danger';
}
// if (['accept', 'pass_review'].includes(actionCode)) {
// return 'success';
// }
return 'primary';
}
/**
* 获取操作动作的展示名称
*/
export function getProjectRequirementActionDisplayName(action: Api.Project.ProjectRequirementLifecycleAction) {
return action.actionName || action.actionCode;
}
/**
* 根据 actionCode 获取对应图标
*/
export function getProjectRequirementActionIcon(actionCode: string) {
const actionKeyword = resolveActionKeyword(actionCode);
return actionKeyword ? ACTION_ICON_MAP[actionKeyword] : DEFAULT_ACTION_ICON;
}