Compare commits

...

4 Commits

Author SHA1 Message Date
dk
f4f43814b3 fix(产品需求): 修复产品需求使用状态和终止态字典的问题。
fix(组织): 修复组织编码下拉框的数据显示问题、修复组织编码负责人无法新增的问题。
fix(管理链路): 修复管理链路高度没固定,节点全部收缩等问题。
2026-05-07 17:09:53 +08:00
dk
991cbb5278 fix(产品需求): 修复需求树的序号展示问题。 2026-05-07 11:02:10 +08:00
dk
67ef8af3fa feat(产品需求): 实现产品需求相关代码。 2026-05-06 17:50:29 +08:00
dk
89cdc62eaa fix(user): 对话框宽度被固定为800px了。 2026-04-28 15:44:15 +08:00
23 changed files with 3658 additions and 39 deletions

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'ReadonlyField' });
interface Props {
value?: string | number | null;
placeholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
value: '',
placeholder: '--'
});
const displayValue = computed(() => {
if (props.value === null || props.value === undefined || props.value === '') {
return props.placeholder;
}
return String(props.value);
});
</script>
<template>
<div class="readonly-field">
{{ displayValue }}
</div>
</template>
<style scoped>
.readonly-field {
display: flex;
align-items: center;
width: 100%;
height: 32px;
padding: 0 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;
cursor: default;
}
</style>

View File

@@ -32,6 +32,30 @@ export const RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE = 'rdms_product_direction';
* 用户所属公司字典编码 * 用户所属公司字典编码
* *
* 对应业务字段:用户相关接口和页面中的 company * 对应业务字段:用户相关接口和页面中的 company
* 来源口径:当前系统用户管理页面按系统字典 system_user_company 做下拉和文案回显 * 来源口径:当前系统"用户管理"页面按系统字典 system_user_company 做下拉和文案回显
*/ */
export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company'; export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company';
/**
* 需求来源类型字典编码
*
* 对应业务字段:需求相关接口和页面中的 sourceType
* 来源口径:产品需求文档中定义,标签包括工单流转、手动新增
*/
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
/**
* 需求优先级字典编码
*
* 对应业务字段:需求相关接口和页面中的 priority
* 来源口径:产品需求文档中定义,标签包括紧急、高、中、低
*/
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
/**
* 需求分类字典编码
*
* 对应业务字段:需求相关接口和页面中的 category
* 来源口径:产品需求文档中定义,标签包括工程需求、用户需求、安全需求、体验优化、功能需求
*/
export const RDMS_REQ_CATEGORY_DICT_CODE = 'rdms_req_category';

View File

@@ -157,6 +157,255 @@ export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
}); });
} }
// ========== 产品需求 API ==========
const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
type RequirementResponse = Omit<
Api.Product.Requirement,
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId'
> & {
id: string | number;
parentId: string | number;
moduleId: string | number;
proposerId: string | number;
currentHandlerUserId?: string | number | null;
implementProjectId?: string | number | null;
sourceBizId?: string | number | null;
children?: RequirementResponse[];
};
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
return {
...requirement,
id: normalizeStringId(requirement.id),
parentId: normalizeStringId(requirement.parentId),
moduleId: normalizeStringId(requirement.moduleId),
proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
children: requirement.children?.map(normalizeRequirement)
};
}
/** 获取需求分页列表 */
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
const result = await request<RequirementPageResponse>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<RequirementPageResponse>, data => ({
...data,
list: data.list.map(normalizeRequirement)
}));
}
/** 获取需求树形列表支持分页pageSize只算父需求 */
export async function fetchGetRequirementTree(params?: Api.Product.RequirementSearchParams) {
const result = await request<Api.Product.PageResult<RequirementResponse>>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/tree`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.PageResult<RequirementResponse>>, data => ({
...data,
list: data.list.map(normalizeRequirement)
}));
}
/** 获取需求详情 */
export async function fetchGetRequirement(id: string, productId: string) {
const result = await request<RequirementResponse>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/get`,
method: 'get',
params: { id, productId }
});
return mapServiceResult(result as ServiceRequestResult<RequirementResponse>, normalizeRequirement);
}
/** 创建需求 */
export async function fetchCreateRequirement(data: Api.Product.SaveRequirementParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新需求 */
export function fetchUpdateRequirement(data: Api.Product.UpdateRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/update`,
method: 'put',
data
});
}
/** 变更需求状态 */
export function fetchChangeRequirementStatus(data: Api.Product.ChangeRequirementStatusParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/change-status`,
method: 'post',
data
});
}
/** 删除需求 */
export function fetchDeleteRequirement(data: Api.Product.DeleteRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/delete`,
method: 'post',
data
});
}
/** 拆分需求 */
export async function fetchSplitRequirement(data: Api.Product.SplitRequirementParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/split`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 关闭需求 */
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/close`,
method: 'post',
data
});
}
/** 获取需求可执行的状态动作列表 */
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
const result = await request<Api.Product.RequirementLifecycleAction[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/allowed-transitions`,
method: 'get',
params: { requirementId, productId }
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data);
}
/** 获取需求生命周期信息 */
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
const result = await request<Api.Product.RequirementLifecycleInfo>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/lifecycle`,
method: 'get',
params: { requirementId, productId }
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleInfo>, data => data);
}
/** 获取需求所有状态字典 */
export async function fetchGetRequirementStatusDict() {
const result = await request<Api.Product.RequirementStatusDict[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
}
/** 获取需求终止态状态字典 */
export async function fetchGetRequirementTerminalStatusDict() {
const result = await request<Api.Product.RequirementStatusDict[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
}
// ========== 模块管理 API ==========
type RequirementModuleResponse = Omit<Api.Product.RequirementModule, 'id' | 'parentId' | 'productId'> & {
id: string | number;
parentId: string | number;
productId: string | number;
children?: RequirementModuleResponse[];
};
function normalizeRequirementModule(module: RequirementModuleResponse): Api.Product.RequirementModule {
return {
...module,
id: normalizeStringId(module.id),
parentId: normalizeStringId(module.parentId),
productId: normalizeStringId(module.productId),
children: module.children?.map(normalizeRequirementModule)
};
}
/** 获取需求模块树 */
export async function fetchGetRequirementModuleTree(productId: string) {
const result = await request<RequirementModuleResponse[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/tree`,
method: 'get',
params: { productId }
});
return mapServiceResult(result as ServiceRequestResult<RequirementModuleResponse[]>, data =>
data.map(normalizeRequirementModule)
);
}
/** 创建需求模块 */
export async function fetchCreateRequirementModule(data: Api.Product.SaveRequirementModuleParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新需求模块 */
export function fetchUpdateRequirementModule(data: Api.Product.SaveRequirementModuleParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/update`,
method: 'put',
data
});
}
/** 删除需求模块 */
export function fetchDeleteRequirementModule(data: Api.Product.DeleteRequirementModuleParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/delete`,
method: 'post',
data
});
}
export async function fetchGetProductSettings(id: string) { export async function fetchGetProductSettings(id: string) {
const result = await request<Api.Product.ProductSettings>({ const result = await request<Api.Product.ProductSettings>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,

View File

@@ -215,5 +215,229 @@ declare namespace Api {
interface InactiveProductMemberParams { interface InactiveProductMemberParams {
reason?: string | null; reason?: string | null;
} }
// ========== 产品需求相关类型定义 ==========
/** 需求状态编码 */
type RequirementStatusCode =
| 'pending_confirm'
| 'pending_review'
| 'pending_dispatch'
| 'implementing'
| 'accepted'
| 'closed'
| 'rejected'
| 'cancelled';
/** 需求来源类型 */
type RequirementSourceType = 'manual' | 'work_order';
/** 需求优先级 */
type RequirementPriority = 0 | 1 | 2 | 3;
/** 是否需要评审 */
type RequirementReviewRequired = 0 | 1;
// ========== 需求实体 ==========
interface Requirement {
/** 需求编号 */
id: string;
/** 产品 ID */
productId: string;
/** 父需求编号0表示顶级需求 */
parentId: string;
/** 所属模块编号 */
moduleId: string;
/** 是否需要评审0不需要1需要 */
reviewRequired: RequirementReviewRequired;
/** 需求标题 */
title: string;
/** 需求描述(富文本) */
description?: string | null;
/** 需求分类字典值 */
category: string;
/** 需求分类名称 */
categoryName?: string | null;
/** 来源类型 */
sourceType: RequirementSourceType;
/** 来源业务ID */
sourceBizId?: string | null;
/** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority;
/** 优先级名称 */
priorityName?: string | null;
/** 当前状态编码 */
statusCode: RequirementStatusCode;
/** 当前状态名称 */
statusName?: string | null;
/** 最近一次状态动作原因 */
lastStatusReason?: string | null;
/** 提出人用户编号 */
proposerId: string;
/** 提出人用户姓名 */
proposerNickname?: string | null;
/** 当前处理人用户编号 */
currentHandlerUserId?: string | null;
/** 当前处理人姓名 */
currentHandlerUserNickname?: string | null;
/** 默认实现项目编号 */
implementProjectId?: string | null;
/** 实现项目名称 */
implementProjectName?: string | null;
/** 预期完成时间 */
completionDate: string;
/** 排序值 */
sort: number;
/** 创建时间 */
createTime: string;
/** 更新时间 */
updateTime: string;
/** 子需求列表(树形结构) */
children?: Requirement[];
/** 是否为终态 */
terminal?: boolean;
}
// ========== 需求模块实体 ==========
interface RequirementModule {
/** 模块编号 */
id: string | undefined;
/** 父模块编号0表示顶级 */
parentId: string | undefined;
/** 所属产品编号 */
productId: string;
/** 模块名称 */
moduleName: string;
/** 模块说明 */
remark?: string | null;
/** 图标 */
icon?: string | null;
/** 排序值 */
sort: number;
/** 子模块列表 */
children?: RequirementModule[];
}
// ========== 需求状态字典 ==========
interface RequirementStatusDict {
/** 状态编码 */
statusCode: string;
/** 状态名称 */
statusName: string;
/** 排序值 */
sort: number;
/** 是否初始状态 */
initialFlag: boolean;
/** 是否终态 */
terminalFlag: boolean;
}
// ========== 需求生命周期 ==========
interface RequirementLifecycleAction {
actionCode: string;
actionName: string;
toStatusCode: string;
toStatusName: string;
needReason: boolean;
}
interface RequirementLifecycleInfo {
statusCode: RequirementStatusCode;
statusName?: string | null;
lastStatusReason?: string | null;
terminal: boolean;
allowEdit: boolean;
availableActions: RequirementLifecycleAction[];
}
// ========== 请求参数类型 ==========
/** 需求分页查询参数 */
type RequirementSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<
Requirement,
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
> & {
productId: string;
title?: string;
}
>;
/** 创建需求参数 */
type SaveRequirementParams = Pick<
Requirement,
| 'productId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'category'
| 'priority'
| 'proposerId'
| 'currentHandlerUserId'
| 'implementProjectId'
| 'completionDate'
| 'sort'
>;
/** 更新需求参数 */
type UpdateRequirementParams = { id: string } & SaveRequirementParams;
/** 变更需求状态参数 */
interface ChangeRequirementStatusParams {
id: string;
productId: string;
actionCode: string;
reason?: string | null;
implementProjectId?: string | null;
}
/** 关闭需求参数 */
interface CloseRequirementParams {
id: string;
productId: string;
reason: string;
}
/** 拆分需求参数 */
type SplitRequirementParams = Pick<
Requirement,
| 'parentId'
| 'productId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'category'
| 'priority'
| 'proposerId'
| 'currentHandlerUserId'
| 'completionDate'
| 'sort'
>;
/** 删除需求参数 */
interface DeleteRequirementParams {
id: string;
productId: string;
}
// ========== 模块请求参数 ==========
/** 保存模块参数 */
type SaveRequirementModuleParams = Pick<
RequirementModule,
'id' | 'productId' | 'parentId' | 'moduleName' | 'remark' | 'icon' | 'sort'
>;
/** 删除模块参数 */
interface DeleteRequirementModuleParams {
id: string | undefined;
productId: string;
}
} }
} }

View File

@@ -103,7 +103,7 @@ declare namespace Api {
interface OrgLeaderRelation { interface OrgLeaderRelation {
id: number; id: number;
deptId: number; deptId: number;
userId: number; userId: string;
userNickname: string; userNickname: string;
effectiveFrom?: number | null; effectiveFrom?: number | null;
effectiveUntil?: number | null; effectiveUntil?: number | null;
@@ -115,7 +115,7 @@ declare namespace Api {
type OrgLeaderRelationList = OrgLeaderRelation[]; type OrgLeaderRelationList = OrgLeaderRelation[];
interface OrgLeaderCandidateUser { interface OrgLeaderCandidateUser {
id: number; id: string;
nickname: string; nickname: string;
deptId: number; deptId: number;
deptName?: string | null; deptName?: string | null;
@@ -125,7 +125,7 @@ declare namespace Api {
type SaveOrgLeaderRelationParams = { type SaveOrgLeaderRelationParams = {
deptId: number; deptId: number;
userId: number; userId: string | null;
effectiveFrom?: number | null; effectiveFrom?: number | null;
effectiveUntil?: number | null; effectiveUntil?: number | null;
remark?: string | null; remark?: string | null;

View File

@@ -122,11 +122,17 @@ declare module 'vue' {
IconMdiChevronDoubleDown: typeof import('~icons/mdi/chevron-double-down')['default'] IconMdiChevronDoubleDown: typeof import('~icons/mdi/chevron-double-down')['default']
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default'] IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default'] IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default'] IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
IconMdiFolderOutline: typeof import('~icons/mdi/folder-outline')['default']
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default'] IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default'] IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default'] IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default'] IconMdiPlus: typeof import('~icons/mdi/plus')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default'] IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
@@ -137,6 +143,8 @@ declare module 'vue' {
LookForward: typeof import('./../components/custom/look-forward.vue')['default'] LookForward: typeof import('./../components/custom/look-forward.vue')['default']
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default'] MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default'] PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
PrioritySelect: typeof import('../views/product/requirement/modules/priority-select.vue')['default']
ReadonlyField: typeof import('./../components/custom/readonly-field.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default'] ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']

View File

@@ -1,7 +1,780 @@
<script setup lang="ts"> <script setup lang="tsx">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import {
RDMS_REQ_CATEGORY_DICT_CODE,
RDMS_REQ_PRIORITY_DICT_CODE
} from '@/constants/dict';
import {
fetchChangeRequirementStatus,
fetchDeleteRequirement,
fetchGetProductMembers,
fetchGetRequirementAllowedTransitions,
fetchGetRequirementStatusDict,
fetchGetRequirementTerminalStatusDict,
fetchGetRequirementTree
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import DictTag from '@/components/custom/dict-tag.vue';
import { useCurrentProduct } from '../shared/use-current-product';
import {
type RequirementStatusActionCode,
getRequirementActionDisplayName,
getRequirementActionTagType,
getRequirementStatusTagType,
isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice,
isRequirementActionTerminal
} from './shared/requirement-master-data';
import RequirementModuleTree from './modules/requirement-module-tree.vue';
import RequirementSearch from './modules/requirement-search.vue';
import RequirementCreateDialog from './modules/requirement-create-dialog.vue';
import RequirementDetailDialog from './modules/requirement-detail-dialog.vue';
import RequirementSplitDialog from './modules/requirement-split-dialog.vue';
import RequirementActionDialog from './modules/requirement-action-dialog.vue';
defineOptions({ name: 'ProductRequirement' }); defineOptions({ name: 'ProductRequirement' });
const { currentObjectId } = useCurrentProduct();
const { hasObjectAuth } = useAuth();
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
const terminalStatusOptions = ref<string[]>([]);
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
if (error || !data) {
statusOptions.value = [];
return;
}
statusOptions.value = data.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
async function loadTerminalStatusOptions() {
const { error, data } = await fetchGetRequirementTerminalStatusDict();
if (error || !data) {
terminalStatusOptions.value = [];
return;
}
terminalStatusOptions.value = data.map(item => item.statusCode);
}
function getStatusLabel(statusCode: string) {
const item = statusOptions.value.find(opt => opt.value === statusCode);
return item ? item.label : statusCode;
}
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
0: 'info',
1: 'primary',
2: 'warning',
3: 'danger'
};
function formatDateTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
function isTerminalStatus(statusCode: string) {
return terminalStatusOptions.value.some(option => option === statusCode);
}
function canSplitRequirement(row: Api.Product.Requirement) {
return row.statusCode === 'pending_dispatch' || row.statusCode === 'implementing';
}
function canDeleteRequirement(row: Api.Product.Requirement) {
const allowedStatusCodes: Api.Product.RequirementStatusCode[] = [
'pending_confirm',
'pending_review',
'pending_dispatch'
];
const isStatusAllowed = allowedStatusCodes.includes(row.statusCode);
const hasNoChildren = !row.children || row.children.length === 0;
return isStatusAllowed && hasNoChildren;
}
const selectedModuleId = ref<string | undefined>('');
const memberOptions = ref<Api.Product.ProductMember[]>([]);
const requirementTableRef = ref<TableInstance>();
const loading = ref(false);
const treeData = ref<Api.Product.Requirement[]>([]);
const pagination = reactive({
pageNo: 1,
pageSize: 10,
total: 0
});
const allowedTransitionsMap = ref<Map<string, Api.Product.RequirementLifecycleAction[]>>(new Map());
const searchParams = reactive({
title: undefined as string | undefined,
category: undefined as string | undefined,
priority: undefined as Api.Product.RequirementPriority | undefined,
statusCode: undefined as Api.Product.RequirementStatusCode | undefined,
currentHandlerUserId: undefined as string | undefined,
sourceType: undefined as Api.Product.RequirementSourceType | undefined
});
const createVisible = ref(false);
const detailVisible = ref(false);
const detailMode = ref<'view' | 'edit'>('view');
const selectedRequirement = ref<Api.Product.Requirement | null>(null);
const splitVisible = ref(false);
const splitParentRequirement = ref<Api.Product.Requirement | null>(null);
const actionVisible = ref(false);
const actionRequirement = ref<Api.Product.Requirement | null>(null);
const currentAction = ref<Api.Product.RequirementLifecycleAction | null>(null);
interface MemberUserOption {
id: string;
nickname: string;
roleName: string;
}
const memberUserOptions = computed<MemberUserOption[]>(() => {
return memberOptions.value
.filter(m => m.status === 0)
.map(m => ({
id: m.userId,
nickname: m.userNickname,
roleName: m.roleName
}));
});
const memberLabelMap = computed(() => {
return new Map(memberUserOptions.value.map(item => [String(item.id), item.nickname]));
});
function getMemberLabel(userId?: string | null) {
if (!userId) {
return '--';
}
return memberLabelMap.value.get(String(userId)) || String(userId);
}
function getPriorityTagType(priority?: number | null): UI.ThemeColor {
if (priority === null || priority === undefined) {
return 'info';
}
return priorityTagTypeMap[priority] || 'info';
}
function flattenTree(nodes: Api.Product.Requirement[]): Api.Product.Requirement[] {
const result: Api.Product.Requirement[] = [];
for (const node of nodes) {
result.push(node);
if (node.children?.length) {
result.push(...flattenTree(node.children));
}
}
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;
}
async function loadAllowedTransitionsForAll() {
if (!currentObjectId.value) {
allowedTransitionsMap.value = new Map();
return;
}
const allIds = collectAllRequirementIds(treeData.value);
const newMap = new Map<string, Api.Product.RequirementLifecycleAction[]>();
const results = await Promise.all(
allIds.map(async id => {
const { error, data } = await fetchGetRequirementAllowedTransitions(id, currentObjectId.value!);
return { id, actions: error ? [] : data || [] };
})
);
for (const { id, actions } of results) {
newMap.set(id, actions);
}
allowedTransitionsMap.value = newMap;
}
function getRowActions(row: Api.Product.Requirement): Api.Product.RequirementLifecycleAction[] {
return allowedTransitionsMap.value.get(row.id) || [];
}
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: 200,
formatter: (row: Api.Product.Requirement) => {
const isTerminal = isTerminalStatus(row.statusCode);
const className = 'requirement-title';
return (
<ElButton link type={isTerminal ? 'info' : 'primary'} class={className} onClick={() => openView(row)}>
{row.title}
</ElButton>
);
}
},
{
prop: 'category',
label: '分类',
minWidth: 120,
formatter: (row: Api.Product.Requirement) => row.category
},
// {
// prop: 'description',
// label: '描述',
// minWidth: 200,
// showOverflowTooltip: true,
// formatter: (row: Api.Product.Requirement) => {
// return row.description?.replace(/<[^>]+>/g, '').trim() || '--';
// }
// },
{
prop: 'priority',
label: '优先级',
width: 100,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
)
},
{
prop: 'statusCode',
label: '状态',
width: 100,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<ElTag type={getRequirementStatusTagType(row.statusCode)}>
{getStatusLabel(row.statusCode)}
</ElTag>
)
},
{
prop: 'currentHandlerUserId',
label: '负责人',
minWidth: 70,
formatter: (row: Api.Product.Requirement) => getMemberLabel(row.currentHandlerUserId)
},
{
prop: 'sourceBizId',
label: '来源业务编号',
minWidth: 140,
formatter: (row: Api.Product.Requirement) => {
if (!row.sourceBizId || row.sourceType === 'manual') {
return '--';
}
return (
<ElButton link type="primary" class="requirement-source-link">
{row.sourceBizId}
</ElButton>
);
}
},
{
prop: 'implementProjectName',
label: '实现项目',
minWidth: 140,
formatter: (row: Api.Product.Requirement) => row.implementProjectName || '--'
},
{
prop: 'createTime',
label: '创建时间',
width: 170,
formatter: (row: Api.Product.Requirement) => formatDateTime(row.createTime)
},
{
prop: 'operate',
label: '操作',
width: 200,
align: 'center',
fixed: 'right',
formatter: (row: Api.Product.Requirement) => {
const actions: {
key: string;
label: string;
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
disabled?: boolean;
onClick: () => void;
}[] = [];
if (canSplitRequirement(row) && hasObjectAuth('project:product:status')) {
actions.push({
key: 'split',
label: '拆分',
buttonType: 'primary',
onClick: () => openSplit(row)
});
}
if (hasObjectAuth('project:product:update')) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'info',
disabled: isTerminalStatus(row.statusCode),
onClick: () => openEdit(row)
});
}
const lifecycleActions = getRowActions(row);
const hasStatusAuth = hasObjectAuth('project:product:status');
if (hasStatusAuth) {
const nonTerminalActions: Api.Product.RequirementLifecycleAction[] = [];
const terminalActions: Api.Product.RequirementLifecycleAction[] = [];
for (const action of lifecycleActions) {
const code = action.actionCode as RequirementStatusActionCode;
if (isRequirementActionTerminal(code)) {
terminalActions.push(action);
} else {
nonTerminalActions.push(action);
}
}
for (const action of [...nonTerminalActions, ...terminalActions]) {
actions.push({
key: `action-${action.actionCode}`,
label: getRequirementActionDisplayName(action),
buttonType: getRequirementActionTagType(action.actionCode as RequirementStatusActionCode),
onClick: () => handleActionClick(row, action)
});
}
}
if (hasStatusAuth && canDeleteRequirement(row)) {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
onClick: () => handleDelete(row)
});
}
return <BusinessTableActionCell actions={actions} />;
}
}
]);
async function loadMembers() {
if (!currentObjectId.value) {
memberOptions.value = [];
return;
}
const { error, data: members } = await fetchGetProductMembers(currentObjectId.value);
if (error || !members) {
memberOptions.value = [];
return;
}
memberOptions.value = members;
}
async function loadTreeData() {
if (!currentObjectId.value) {
treeData.value = [];
pagination.total = 0;
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirementTree({
productId: currentObjectId.value,
moduleId: selectedModuleId.value,
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
title: searchParams.title,
category: searchParams.category,
priority: searchParams.priority,
statusCode: searchParams.statusCode,
currentHandlerUserId: searchParams.currentHandlerUserId,
sourceType: searchParams.sourceType
});
loading.value = false;
if (error || !data) {
treeData.value = [];
pagination.total = 0;
return;
}
treeData.value = data.list;
pagination.total = data.total;
}
async function reloadTable() {
await loadTreeData();
await loadAllowedTransitionsForAll();
}
function handleModuleSelect(moduleId: string | undefined) {
selectedModuleId.value = moduleId;
pagination.pageNo = 1;
reloadTable();
}
function handleSearch() {
pagination.pageNo = 1;
reloadTable();
}
function handleResetSearch() {
searchParams.title = undefined;
searchParams.category = undefined;
searchParams.priority = undefined;
searchParams.statusCode = undefined;
searchParams.currentHandlerUserId = undefined;
searchParams.sourceType = undefined;
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.Product.Requirement) {
selectedRequirement.value = row;
detailMode.value = 'view';
detailVisible.value = true;
}
function openEdit(row: Api.Product.Requirement) {
selectedRequirement.value = row;
detailMode.value = 'edit';
detailVisible.value = true;
}
function openSplit(row: Api.Product.Requirement) {
splitParentRequirement.value = row;
splitVisible.value = true;
}
function handleActionClick(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
const actionCode = action.actionCode as RequirementStatusActionCode;
if (
!isRequirementActionNeedReviewChoice(actionCode) &&
!isRequirementActionNeedProject(actionCode) &&
!isRequirementActionTerminal(actionCode)
) {
handleDirectAction(row, action);
return;
}
actionRequirement.value = row;
currentAction.value = action;
actionVisible.value = true;
}
async function handleDirectAction(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
if (!currentObjectId.value) return;
try {
await window.$messageBox?.confirm(`确定要执行"${action.actionName}"操作吗?`, '确认操作', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'info'
});
} catch {
return;
}
const { error } = await fetchChangeRequirementStatus({
id: row.id,
productId: currentObjectId.value,
actionCode: action.actionCode
});
if (error) {
return;
}
window.$message?.success(`${action.actionName}成功`);
await reloadTable();
}
async function handleActionSubmitted(payload: { actionCode: string; reason?: string; implementProjectId?: string }) {
if (!currentObjectId.value || !actionRequirement.value) return;
const { error } = await fetchChangeRequirementStatus({
id: actionRequirement.value.id,
productId: currentObjectId.value,
actionCode: payload.actionCode,
reason: payload.reason,
implementProjectId: payload.implementProjectId
});
if (error) {
return;
}
window.$message?.success('操作成功');
actionVisible.value = false;
await reloadTable();
}
async function handleDelete(row: Api.Product.Requirement) {
if (!currentObjectId.value) return;
try {
await window.$messageBox?.confirm('确定要删除该需求吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
const { error } = await fetchDeleteRequirement({
id: row.id,
productId: 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 => {
if (id) {
await Promise.all([loadMembers(), loadTreeData()]);
await loadAllowedTransitionsForAll();
} else {
memberOptions.value = [];
treeData.value = [];
allowedTransitionsMap.value = new Map();
}
},
{ immediate: true }
);
onMounted(async () => {
await Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
});
</script> </script>
<template> <template>
<h1>待开发</h1> <div
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[280px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<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="flex items-center justify-between gap-12px">
<div class="flex flex-wrap items-center gap-8px">
<p>需求列表</p>
<ElTag effect="plain">{{ pagination.total }} </ElTag>
</div>
<TableHeaderOperation :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton
v-auth="{ code: 'project:product: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
ref="requirementTableRef"
v-loading="loading"
border
lazy
row-key="id"
:indent="32"
height="100%"
:data="treeData"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<ElTableColumn v-for="col in columns" :key="String(col.prop || 'index')" v-bind="col" />
<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"
:product-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"
:product-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"
:product-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>
</template> </template>
<style lang="scss" scoped>
:deep(.requirement-table-card-body) {
height: calc(100% - 56px);
display: flex;
flex-direction: column;
}
:deep(.requirement-title) {
padding: 0;
font-weight: 500;
}
:deep(.requirement-title--terminal) {
padding: 0;
text-decoration: line-through;
opacity: 0.6;
}
:deep(.requirement-source-link) {
padding: 0;
}
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
color: transparent;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ElTag } from 'element-plus';
defineOptions({ name: 'MemberSelectOption' });
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,313 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'ModuleTreeNode' });
interface Props {
module: Api.Product.RequirementModule;
level?: number;
selectedModuleId?: string;
editingNodeId?: string | undefined;
editingName?: string;
addingChildParentId?: string | undefined;
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 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(() => props.module.children && props.module.children.length > 0);
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 handleStartEdit() {
emit('edit', props.module);
}
function handleEditConfirm() {
emit('editConfirm', props.module);
}
function handleEditCancel() {
emit('editCancel');
}
function handleStartAddChild() {
emit('addChild', props.module);
}
function handleDelete() {
emit('delete', props.module);
}
function handleAddChildConfirm() {
emit('addChildConfirm');
}
function handleAddChildCancel() {
emit('addChildCancel');
}
</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__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="handleEditConfirm"
@keyup.enter="handleEditConfirm"
@keyup.esc="handleEditCancel"
/>
</div>
<div v-if="!isRootModule && !isEditing" class="module-tree-item__actions">
<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"
>
<div class="flex items-center gap-6px">
<icon-ic-round-plus class="text-14px" />
<span>新增子模块</span>
</div>
</ElDropdownItem>
<ElDropdownItem v-auth="{ code: 'project:product:update', source: 'object' }" @click="handleStartEdit">
<div class="flex items-center gap-6px">
<icon-mdi-pencil-outline class="text-14px" />
<span>编辑</span>
</div>
</ElDropdownItem>
<ElDropdownItem
v-if="canDeleteModule"
v-auth="{ code: 'project:product:delete', source: 'object' }"
divided
@click="handleDelete"
>
<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">
<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="handleAddChildConfirm"
@keyup.enter="handleAddChildConfirm"
@keyup.esc="handleAddChildCancel"
/>
</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__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,154 @@
<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';
import {
type RequirementStatusActionCode,
isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice,
isRequirementActionTerminal
} from '../shared/requirement-master-data';
defineOptions({ name: 'RequirementActionDialog' });
interface Props {
action: Api.Product.RequirementLifecycleAction | null;
requirementTitle: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', payload: { actionCode: string; reason?: string; implementProjectId?: string }): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
interface Model {
reviewChoice: string;
implementProjectId: string;
reason: string;
}
const model = ref<Model>({ reviewChoice: '', implementProjectId: '', reason: '' });
const submitting = ref(false);
const actionCode = computed(() => props.action?.actionCode as RequirementStatusActionCode | undefined);
const isClaimAction = computed(() =>
actionCode.value ? isRequirementActionNeedReviewChoice(actionCode.value) : false
);
const isDispatchAction = computed(() => (actionCode.value ? isRequirementActionNeedProject(actionCode.value) : false));
const isTerminalAction = computed(() => (actionCode.value ? isRequirementActionTerminal(actionCode.value) : false));
const dialogTitle = computed(() => {
if (!props.action) return '';
if (isClaimAction.value) return '认领需求';
return props.action.actionName;
});
const reviewChoiceOptions = [
{ label: '需要评审', value: 'claim_to_review', description: '认领后进入评审流程' },
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入分流' }
];
const projectOptions = [{ label: 'NPQS-10086', value: '202642910086' }];
const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = {};
if (isClaimAction.value) {
baseRules.reviewChoice = [createRequiredRule('请选择是否需要评审')];
}
if (isDispatchAction.value) {
baseRules.implementProjectId = [createRequiredRule('请选择实现项目')];
}
if (isTerminalAction.value) {
baseRules.reason = [createRequiredRule('请输入状态变更原因')];
}
return baseRules;
});
watch(
() => visible.value,
val => {
if (val) {
model.value = { reviewChoice: '', implementProjectId: '', reason: '' };
}
}
);
async function handleSubmit() {
await validate();
submitting.value = true;
const payload: { actionCode: string; reason?: string; implementProjectId?: string } = {
actionCode: isClaimAction.value ? model.value.reviewChoice : props.action!.actionCode
};
if (isDispatchAction.value) {
payload.implementProjectId = model.value.implementProjectId;
}
if (isTerminalAction.value) {
payload.reason = model.value.reason.trim();
}
emit('submitted', payload);
submitting.value = false;
visible.value = false;
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="sm"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElFormItem label="需求标题">
<span class="text-14px">{{ requirementTitle }}</span>
</ElFormItem>
<ElFormItem v-if="isClaimAction" label="是否需要评审" prop="reviewChoice">
<ElRadioGroup v-model="model.reviewChoice" class="business-form-radio-group">
<ElRadio v-for="option in reviewChoiceOptions" :key="option.value" :value="option.value">
<div class="flex flex-col gap-2px">
<span>{{ option.label }}</span>
<span class="text-12px text-gray-400">{{ option.description }}</span>
</div>
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<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.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="isTerminalAction" label="变更原因" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入状态变更原因(必填)"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { fetchCreateRequirement, fetchGetRequirementModuleTree } 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 DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementCreateDialog' });
interface Props {
productId: string;
defaultModuleId?: string;
memberOptions: Api.Product.ProductMember[];
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;
completionDate: string;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
currentHandlerUserId: string;
sort: number;
}
const submitting = ref(false);
const loading = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0);
});
const moduleTreeProps = {
label: 'moduleName',
value: 'id',
children: 'children'
};
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入需求标题')],
category: [createRequiredRule('请选择分类')],
priority: [createRequiredRule('请选择优先级')],
proposerId: [createRequiredRule('请选择提出人')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
completionDate: '',
moduleId: props.defaultModuleId || '0',
category: '功能需求',
priority: 1,
proposerId: '',
currentHandlerUserId: '',
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
if (!props.productId) {
return;
}
const payload: Api.Product.SaveRequirementParams = {
productId: props.productId,
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
currentHandlerUserId: model.value.currentHandlerUserId,
implementProjectId: null,
completionDate: model.value.completionDate,
sort: model.value.sort
};
submitting.value = true;
const result = await fetchCreateRequirement(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('需求新增成功');
closeDialog();
emit('submitted');
}
async function loadModuleTree() {
if (!props.productId) {
moduleTree.value = [];
return;
}
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
}
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"
:loading="loading"
: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="预期完成时间" prop="completionDate" style="width: 100%">
<ElDatePicker
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<ElInput
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
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="模块">
<ElTreeSelect
v-model="model.moduleId"
:data="moduleTree"
:props="moduleTreeProps"
class="w-full"
check-strictly
:render-after-expand="false"
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="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="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,433 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { fetchGetRequirement, fetchGetRequirementModuleTree, fetchUpdateRequirement } 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 DictSelect from '@/components/custom/dict-select.vue';
import ReadonlyField from '@/components/custom/readonly-field.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementDetailDialog' });
type DialogMode = 'view' | 'edit';
interface Props {
mode: DialogMode;
requirement: Api.Product.Requirement | null;
productId: string;
memberOptions: Api.Product.ProductMember[];
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;
completionDate: string;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
currentHandlerUserId: string;
implementProjectId: string | null;
sort: number;
lastStatusReason: string;
}
const loading = ref(false);
const submitting = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const model = ref<Model>(createDefaultModel());
const isViewMode = computed(() => props.mode === 'view');
const isEditMode = computed(() => props.mode === 'edit');
const dialogTitle = computed(() => {
if (isViewMode.value) {
return '查看需求';
}
return '编辑需求';
});
const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0);
});
const memberLabelMap = computed(() => {
return new Map(memberUserOptions.value.map(item => [String(item.userId), item.userNickname]));
});
const moduleLabelMap = computed(() => {
const map = new Map<string | undefined, string>();
function traverse(modules: Api.Product.RequirementModule[]) {
for (const module of modules) {
map.set(module.id, module.moduleName);
if (module.children?.length) {
traverse(module.children);
}
}
}
traverse(moduleTree.value);
return map;
});
const moduleTreeProps = {
label: 'moduleName',
value: 'id',
children: 'children'
};
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = {
title: isEditMode.value ? [createRequiredRule('请输入需求标题')] : [],
category: isEditMode.value ? [createRequiredRule('请选择分类')] : [],
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : [],
completionDate: isEditMode.value ? [createRequiredRule('请选择预期完成时间')] : []
};
return baseRules;
});
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
completionDate: '',
moduleId: '0',
category: '',
priority: 1,
proposerId: '',
currentHandlerUserId: '',
implementProjectId: null,
sort: 0,
lastStatusReason: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
if (!props.productId || !props.requirement?.id) {
return;
}
submitting.value = true;
const updatePayload: Api.Product.UpdateRequirementParams = {
id: props.requirement.id,
productId: props.productId,
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
currentHandlerUserId: model.value.currentHandlerUserId,
implementProjectId: model.value.implementProjectId,
completionDate: model.value.completionDate,
sort: model.value.sort
};
const { error } = await fetchUpdateRequirement(updatePayload);
submitting.value = false;
if (error) {
return;
}
window.$message?.success('需求更新成功');
closeDialog();
emit('submitted', props.requirement.id);
}
async function loadModuleTree() {
if (!props.productId) {
moduleTree.value = [];
return;
}
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
}
async function loadRequirementDetail() {
if (!props.productId || !props.requirement?.id) {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirement(props.requirement.id, props.productId);
loading.value = false;
if (error || !data) {
return;
}
model.value = {
title: data.title || '',
description: data.description || '',
reviewRequired: data.reviewRequired ?? 0,
completionDate: data.completionDate || '',
moduleId: data.moduleId || '0',
category: data.category || '',
priority: data.priority ?? null,
proposerId: data.proposerId || '',
currentHandlerUserId: data.currentHandlerUserId || '',
implementProjectId: data.implementProjectId || null,
sort: data.sort ?? 0,
lastStatusReason: data.lastStatusReason || ''
};
}
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">
<template v-if="isViewMode">
<ReadonlyField :value="model.title" />
</template>
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
<template v-if="isViewMode">
<ReadonlyField
:value="model.completionDate ? dayjs(Number(model.completionDate)).format('YYYY-MM-DD HH:mm:ss') : '--'"
/>
</template>
<ElDatePicker
v-else
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<template v-if="isViewMode">
<div class="readonly-textarea">
{{ model.description || '--' }}
</div>
</template>
<ElInput
v-else
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
placeholder="请输入需求描述"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<template v-if="isViewMode">
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
</template>
<ElTreeSelect
v-else
v-model="model.moduleId"
:data="moduleTree"
:props="moduleTreeProps"
class="w-full"
check-strictly
:render-after-expand="false"
placeholder="请选择所属模块"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="分类" prop="category">
<template v-if="isViewMode">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</template>
<DictSelect
v-else
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择分类"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<template v-if="isViewMode">
<ReadonlyField
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
/>
</template>
<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="提出人" prop="proposerId">
<template v-if="isViewMode">
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</template>
<ElSelect v-else 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">
<template v-if="isViewMode">
<ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
</template>
<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 :value="model.implementProjectId || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<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>
</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,451 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
fetchCreateRequirementModule,
fetchDeleteRequirementModule,
fetchGetRequirementModuleTree,
fetchUpdateRequirementModule
} from '@/service/api';
import { useCurrentProduct } from '../../shared/use-current-product';
import ModuleTreeNode from './module-tree-node.vue';
defineOptions({ name: 'RequirementModuleTree' });
interface Props {
requirementTree?: Api.Product.Requirement[];
}
const props = withDefaults(defineProps<Props>(), {
requirementTree: () => []
});
interface Emits {
(e: 'select', moduleId: string | undefined): void;
(e: 'refresh'): void;
}
const emit = defineEmits<Emits>();
const { currentObjectId } = useCurrentProduct();
const loading = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const selectedModuleId = ref<string | undefined>(undefined);
const rootModule = computed<Api.Product.RequirementModule | null>(() => {
if (moduleTree.value.length === 0) return null;
return moduleTree.value[0];
});
const editingNodeId = ref<string | undefined>(undefined);
const editingName = ref('');
const addingTopModule = ref(false);
const newModuleName = ref('');
const addingChildParentId = ref<string | undefined>(undefined);
const newChildModuleName = ref('');
const moduleRequirementCountMap = computed(() => {
const countMap = new Map<string, number>();
function countRequirementsByModule(nodes: Api.Product.Requirement[]): void {
for (const node of nodes) {
const currentCount = countMap.get(node.moduleId) || 0;
countMap.set(node.moduleId, currentCount + 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;
}
loading.value = true;
const { error, data } = await fetchGetRequirementModuleTree(currentObjectId.value);
loading.value = false;
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 startAddTopModule() {
if (addingTopModule.value || addingChildParentId.value) return;
addingTopModule.value = true;
newModuleName.value = '';
nextTick(() => {
const input = document.querySelector('.new-module-input input') as HTMLInputElement;
input?.focus();
});
}
async function handleAddTopModuleConfirm() {
const name = newModuleName.value.trim();
if (!name) {
addingTopModule.value = false;
newModuleName.value = '';
return;
}
if (!currentObjectId.value || !rootModule.value?.id) {
addingTopModule.value = false;
return;
}
const { error } = await fetchCreateRequirementModule({
id: undefined,
productId: currentObjectId.value,
parentId: rootModule.value.id,
moduleName: name,
remark: null,
icon: null,
sort: 0
});
if (error) {
addingTopModule.value = false;
return;
}
window.$message?.success('模块新增成功');
addingTopModule.value = false;
newModuleName.value = '';
await loadModuleTree();
emit('refresh');
}
function handleAddTopModuleCancel() {
addingTopModule.value = false;
newModuleName.value = '';
}
function handleStartEdit(module: Api.Product.RequirementModule) {
editingNodeId.value = module.id;
editingName.value = module.moduleName;
nextTick(() => {
const input = document.querySelector('.module-tree-item.is-editing .el-input__inner') as HTMLInputElement;
input?.focus();
input?.select();
});
}
async function handleEditConfirm(module: Api.Product.RequirementModule) {
const name = editingName.value.trim();
editingNodeId.value = undefined;
if (!name || name === module.moduleName) {
return;
}
await handleUpdateModuleName(module, name);
}
function handleEditCancel() {
editingNodeId.value = undefined;
}
async function handleUpdateModuleName(module: Api.Product.RequirementModule, name: string) {
if (!currentObjectId.value) return;
const { error } = await fetchUpdateRequirementModule({
id: module.id,
productId: currentObjectId.value,
parentId: module.parentId,
moduleName: name,
remark: module.remark,
icon: module.icon,
sort: module.sort
});
if (error) return;
window.$message?.success('模块名称更新成功');
await loadModuleTree();
emit('refresh');
}
function handleStartAddChild(module: Api.Product.RequirementModule) {
if (addingTopModule.value || addingChildParentId.value) return;
addingChildParentId.value = module.id;
newChildModuleName.value = '';
nextTick(() => {
const input = document.querySelector('.new-child-module-input input') as HTMLInputElement;
input?.focus();
});
}
async function handleAddChildConfirm() {
const name = newChildModuleName.value.trim();
const parentId = addingChildParentId.value;
addingChildParentId.value = undefined;
newChildModuleName.value = '';
if (!name) {
return;
}
if (!currentObjectId.value) {
return;
}
if (!parentId) {
return;
}
const { error } = await fetchCreateRequirementModule({
id: undefined,
productId: currentObjectId.value,
parentId,
moduleName: name,
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.Product.RequirementModule) {
if (!currentObjectId.value) return;
try {
await ElMessageBox.confirm(
`确定要删除模块 "${module.moduleName}" 吗?该模块下的所有需求将被一并删除。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
);
} catch {
return;
}
const { error } = await fetchDeleteRequirementModule({
id: module.id,
productId: 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) {
selectedModuleId.value = '';
await loadModuleTree();
} else {
moduleTree.value = [];
}
},
{ 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>
<ElSpace>
<ElButton
v-auth="{ code: 'project:product:create', source: 'object' }"
circle
text
size="small"
@click="startAddTopModule"
>
<template #icon>
<icon-ic-round-plus class="text-16px" />
</template>
</ElButton>
</ElSpace>
</div>
<div class="module-tree-list">
<template v-for="data in moduleTree" :key="data.id">
<ModuleTreeNode
:module="data"
: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 v-if="addingTopModule" class="module-tree-item module-tree-item--new">
<div class="module-tree-item__icon">
<icon-mdi-folder-plus-outline class="text-16px" />
</div>
<div class="module-tree-item__content">
<ElInput
v-model="newModuleName"
size="small"
class="new-module-input module-tree-item__input"
placeholder="请输入模块名"
@blur="handleAddTopModuleConfirm"
@keyup.enter="handleAddTopModuleConfirm"
@keyup.esc="handleAddTopModuleCancel"
/>
</div>
</div>
</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;
}
.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-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
}
.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__content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.module-tree-item__input {
width: 100%;
}
.module-tree-item__input :deep(.el-input__inner) {
height: 28px;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetRequirementStatusDict } from '@/service/api';
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: 'RequirementSearch' });
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.Product.RequirementSearchParams>('model', { required: true });
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
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="来源类型">
<DictSelect
v-model="model.sourceType"
:dict-code="RDMS_REQ_SOURCE_TYPE_DICT_CODE"
clearable
placeholder="筛选来源类型"
/>
</ElFormItem>
</ElCol>
</TableSearchPanel>
</template>
<style scoped></style>

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { fetchSplitRequirement } 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 DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementSplitDialog' });
interface Props {
parentRequirement: Api.Product.Requirement | null;
productId: string;
memberOptions: Api.Product.ProductMember[];
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;
completionDate: string;
sort: number;
}
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0);
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入子需求标题')],
category: [createRequiredRule('请选择分类')],
priority: [createRequiredRule('请选择优先级')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
category: '',
priority: 1,
currentHandlerUserId: '',
completionDate: '',
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
if (!props.productId || !props.parentRequirement?.id) {
return;
}
const payload: Api.Product.SplitRequirementParams = {
parentId: props.parentRequirement.id,
productId: props.productId,
moduleId: props.parentRequirement.moduleId,
proposerId: props.parentRequirement.proposerId,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
currentHandlerUserId: model.value.currentHandlerUserId,
completionDate: model.value.completionDate,
sort: model.value.sort
};
console.log('payload', payload);
submitting.value = true;
const result = await fetchSplitRequirement(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('需求拆分成功');
closeDialog();
emit('submitted');
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.value = createDefaultModel();
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="拆分需求"
preset="lg"
:loading="loading"
: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="预期完成时间" prop="completionDate" style="width: 100%">
<ElDatePicker
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<ElInput
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
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="category">
<DictSelect v-model="model.category" :dict-code="categoryDictCode" filterable 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="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,98 @@
import { transformRecordToOption } from '@/utils/common';
export type RequirementStatusActionCode =
| 'claim_to_review'
| 'claim_to_dispatch'
| 'reject'
| 'to_dispatch'
| 'dispatch'
| 'cancel'
| 'accept'
| 'close';
export const requirementStatusRecord: Record<Api.Product.RequirementStatusCode, string> = {
pending_confirm: '待确认',
pending_review: '待评审',
pending_dispatch: '待分流',
implementing: '实施中',
accepted: '已验收',
closed: '已关闭',
rejected: '已拒绝',
cancelled: '已取消'
};
export const requirementStatusOptions = transformRecordToOption(requirementStatusRecord);
export const requirementStatusActionRecord: Record<RequirementStatusActionCode, string> = {
claim_to_review: '认领',
claim_to_dispatch: '认领',
reject: '拒绝',
to_dispatch: '评审通过',
dispatch: '分流',
cancel: '取消',
accept: '验收通过',
close: '关闭'
};
export function getRequirementStatusLabel(status: Api.Product.RequirementStatusCode) {
return requirementStatusRecord[status];
}
export function getRequirementStatusTagType(status: Api.Product.RequirementStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Product.RequirementStatusCode, UI.ThemeColor> = {
pending_confirm: 'info',
pending_review: 'warning',
pending_dispatch: 'primary',
implementing: 'primary',
accepted: 'success',
closed: 'info',
rejected: 'danger',
cancelled: 'danger'
};
return statusTagTypeMap[status];
}
export function getRequirementActionLabel(actionCode: RequirementStatusActionCode) {
return requirementStatusActionRecord[actionCode];
}
export function getRequirementActionTagType(
actionCode: RequirementStatusActionCode
): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
const actionTagTypeMap: Record<RequirementStatusActionCode, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
claim_to_review: 'primary',
claim_to_dispatch: 'primary',
reject: 'danger',
to_dispatch: 'success',
dispatch: 'primary',
cancel: 'danger',
accept: 'success',
close: 'info'
};
return actionTagTypeMap[actionCode];
}
export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) {
const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close'];
return terminalActions.includes(actionCode);
}
export function isRequirementActionNeedProject(actionCode: RequirementStatusActionCode) {
return actionCode === 'dispatch';
}
export function isRequirementActionNeedReviewChoice(actionCode: RequirementStatusActionCode) {
return actionCode === 'claim_to_review' || actionCode === 'claim_to_dispatch';
}
export function getRequirementActionDisplayName(action: Api.Product.RequirementLifecycleAction): string {
const code = action.actionCode as RequirementStatusActionCode;
if (code === 'claim_to_review' || code === 'claim_to_dispatch') {
return '认领';
}
return action.actionName;
}

View File

@@ -7,7 +7,7 @@
* - 支持节点的展开/折叠 * - 支持节点的展开/折叠
* - 支持单选/多选节点 * - 支持单选/多选节点
* - 提供新增、编辑、删除(单个/批量)功能 * - 提供新增、编辑、删除(单个/批量)功能
* - 支持按管理者用户 ID 和被管理用户 ID 搜索 * - 支持按上级用户 ID 和下级用户 ID 搜索
* *
* 树形结构特点: * 树形结构特点:
* - 根节点:最高领导,没有上级 * - 根节点:最高领导,没有上级
@@ -134,6 +134,10 @@ async function loadTreeData() {
if (!error) { if (!error) {
treeData.value = data || []; treeData.value = data || [];
// 数据加载完成后,展开前两层节点
await nextTick();
expandFirstTwoLevels();
} }
} finally { } finally {
loading.value = false; loading.value = false;
@@ -167,6 +171,9 @@ async function loadTreeDataByQuery(query: Api.SystemManage.UserManagementRelatio
* 清空选中状态并重新加载数据 * 清空选中状态并重新加载数据
*/ */
async function reloadTreeData() { async function reloadTreeData() {
// 保存当前展开状态
saveExpandedState();
checkedNodeKeys.value = []; checkedNodeKeys.value = [];
await loadTreeData(); await loadTreeData();
await nextTick(); await nextTick();
@@ -216,15 +223,25 @@ const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateM
const operateType = ref<UI.TableOperateType>('add'); const operateType = ref<UI.TableOperateType>('add');
const editingData = ref<Api.SystemManage.UserManagementRelation | null>(null); const editingData = ref<Api.SystemManage.UserManagementRelation | null>(null);
/**
* 是否在管理链路树中选中节点后点击新增
* 用于控制新增对话框中上级用户下拉框是否禁用
*/
const isAddFromTreeNode = ref(false);
/** /**
* 打开新增对话框 * 打开新增对话框
* *
* @param item 当前节点数据,用于设置默认管理者为此节点用户 * @param item 当前节点数据,用于设置默认上级为此节点用户
*/ */
function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) { function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
operateType.value = 'add'; operateType.value = 'add';
// 如果是从某一行的新增按钮触发,则默认管理者为当前节点用户
// 否则默认管理者为当前登录用户(在对话框组件中处理) // 如果是从树节点点击的新增按钮,标记为来自树节点
isAddFromTreeNode.value = Boolean(item);
// 如果是从某一行的新增按钮触发,则默认上级为当前节点用户
// 否则默认上级为当前登录用户(在对话框组件中处理)
editingData.value = item editingData.value = item
? { ? {
id: null, id: null,
@@ -309,14 +326,127 @@ function handleNodeCheck(checkedData: any, checkedInfo: any) {
.filter((id: string | null): id is string => Boolean(id)); .filter((id: string | null): id is string => Boolean(id));
} }
/**
* 保存当前展开的节点 ID 列表
* 用于在刷新数据后恢复展开状态
*/
const expandedNodeKeys = ref<string[]>([]);
/**
* 保存当前展开的节点状态
*/
function saveExpandedState() {
if (!relationTreeRef.value) {
return;
}
const store = (relationTreeRef.value as any).store;
if (!store) {
return;
}
const allNodes = store.nodesMap || {};
expandedNodeKeys.value = [];
Object.keys(allNodes).forEach(key => {
const node = allNodes[key];
if (node && node.expanded && node.data && node.data.userId) {
expandedNodeKeys.value.push(node.data.userId);
}
});
}
/** /**
* 处理对话框提交事件 * 处理对话框提交事件
* *
* @param relationId 提交后的关系 ID * @param relationId 提交后的关系 ID
*/ */
function handleSubmitted(_relationId: string) { async function handleSubmitted(_relationId: string) {
closeOperateModal(); closeOperateModal();
reloadTreeData(); await reloadTreeData();
// 操作完成后恢复树节点的展开状态
await restoreExpandedState();
// 重置标记
isAddFromTreeNode.value = false;
}
/**
* 展开所有子节点(递归)
*
* @param tree 树形组件实例
* @param nodes 节点数据数组
*/
function expandNodes(tree: InstanceType<typeof ElTree>, nodes: Api.SystemManage.UserManagementRelationTreeRespVO[]) {
if (!tree || !nodes || !nodes.length) {
return;
}
for (const node of nodes) {
// 展开当前节点
const treeNode = tree.getNode(node.userId);
if (treeNode) {
treeNode.expand();
}
// 递归展开子节点
if (node.children && node.children.length > 0) {
expandNodes(tree, node.children);
}
}
}
/**
* 展开树的前两层节点
*
* 只展开根节点和它们的直接子节点,第三层及更深层保持折叠
*/
function expandFirstTwoLevels() {
const tree = relationTreeRef.value;
if (!tree || !treeData.value.length) {
return;
}
// 展开第一层(根节点)
for (const rootNode of treeData.value) {
const treeNode = tree.getNode(rootNode.userId);
if (treeNode) {
treeNode.expand();
}
// 展开第二层(根节点的直接子节点)
if (rootNode.children && rootNode.children.length > 0) {
for (const childNode of rootNode.children) {
const childTreeNode = tree.getNode(childNode.userId);
if (childTreeNode) {
childTreeNode.expand();
}
}
}
}
}
/**
* 恢复树节点的展开状态
*
* 根据之前保存的展开状态,恢复对应的节点展开
*/
async function restoreExpandedState() {
await nextTick();
const tree = relationTreeRef.value;
if (!tree || !expandedNodeKeys.value.length) {
return;
}
// 恢复之前展开的节点
for (const key of expandedNodeKeys.value) {
const node = tree.getNode(key);
if (node) {
node.expand();
}
}
} }
/** /**
@@ -356,7 +486,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="flex-col-stretch gap-16px overflow-hidden"> <div class="flex-col-stretch gap-16px overflow-hidden" style="height: calc(70vh - 120px)">
<!-- 搜索区域 --> <!-- 搜索区域 -->
<RelationSearch <RelationSearch
v-model:model="searchParams" v-model:model="searchParams"
@@ -366,7 +496,7 @@ onMounted(async () => {
/> />
<!-- 树形卡片区域 --> <!-- 树形卡片区域 -->
<ElCard class="flex-1-hidden card-wrapper"> <ElCard class="flex-1-hidden card-wrapper min-h-0">
<template #header> <template #header>
<div class="flex items-center justify-between gap-12px"> <div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px"> <div class="flex items-center gap-10px">
@@ -463,6 +593,7 @@ onMounted(async () => {
:operate-type="operateType" :operate-type="operateType"
:row-data="editingData" :row-data="editingData"
:user-list="userList" :user-list="userList"
:is-add-from-tree-node="isAddFromTreeNode"
@submitted="handleSubmitted" @submitted="handleSubmitted"
/> />
</div> </div>

View File

@@ -8,8 +8,8 @@
* - 表单验证和提交 * - 表单验证和提交
* *
* 表单字段: * 表单字段:
* - 管理者用户:必填,下拉选择,默认当前登录用户 * - 上级用户:必填,下拉选择,默认当前登录用户
* - 被管理用户:必填,下拉选择,默认空 * - 下级用户用户:必填,下拉选择,默认空
* - 生效开始时间:可选 * - 生效开始时间:可选
* - 生效结束时间:可选 * - 生效结束时间:可选
* - 备注:可选 * - 备注:可选
@@ -39,6 +39,8 @@ interface Props {
rowData?: Api.SystemManage.UserManagementRelation | null; rowData?: Api.SystemManage.UserManagementRelation | null;
/** 用户列表,由父组件统一提供 */ /** 用户列表,由父组件统一提供 */
userList: Api.SystemManage.UserSimple[]; userList: Api.SystemManage.UserSimple[];
/** 是否从树节点点击新增(用于控制上级用户下拉框禁用) */
isAddFromTreeNode?: boolean;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@@ -115,8 +117,8 @@ function createDefaultModel(): Model {
* 表单验证规则 * 表单验证规则
*/ */
const rules = { const rules = {
managerUserId: createRequiredRule('请选择管理者用户'), managerUserId: createRequiredRule('请选择上级用户'),
subordinateUserId: createRequiredRule('请选择被管理用户') subordinateUserId: createRequiredRule('请选择下级用户')
} satisfies Record<string, App.Global.FormRule>; } satisfies Record<string, App.Global.FormRule>;
/** /**
@@ -162,16 +164,16 @@ async function initModel() {
model.value = createDefaultModel(); model.value = createDefaultModel();
if (!isEdit.value) { if (!isEdit.value) {
// 新增模式:设置管理者用户 // 新增模式:设置上级用户
// 优先使用 rowData 中传入的管理者用户 ID如从树形节点新增 // 优先使用 rowData 中传入的上级用户 ID如从树形节点新增
// 否则使用当前登录用户 // 否则使用当前登录用户
let managerUserIdToSet = resolveDefaultManagerUserId(); let managerUserIdToSet = resolveDefaultManagerUserId();
if (props.rowData && props.rowData.managerUserId) { if (props.rowData && props.rowData.managerUserId) {
// 从树形节点点击新增,管理者为当前节点用户 // 从树形节点点击新增,上级为当前节点用户
managerUserIdToSet = props.rowData.managerUserId; managerUserIdToSet = props.rowData.managerUserId;
} else if (authStore.userInfo.userId) { } else if (authStore.userInfo.userId) {
// 头部新增,管理者为当前登录用户 // 头部新增,上级为当前登录用户
const currentUserId = authStore.userInfo.userId; const currentUserId = authStore.userInfo.userId;
const currentUserName = authStore.userInfo.userName; const currentUserName = authStore.userInfo.userName;
@@ -301,18 +303,24 @@ watch(visible, value => {
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16"> <ElRow :gutter="16">
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="管理者用户" prop="managerUserId"> <ElFormItem label="上级用户" prop="managerUserId">
<ElSelect v-model="model.managerUserId" class="w-full" placeholder="请选择管理者用户" filterable> <ElSelect
v-model="model.managerUserId"
class="w-full"
placeholder="请选择上级用户"
filterable
:disabled="props.operateType === 'add' && props.isAddFromTreeNode"
>
<ElOption v-for="user in props.userList" :key="user.id" :label="user.nickname" :value="user.id" /> <ElOption v-for="user in props.userList" :key="user.id" :label="user.nickname" :value="user.id" />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="被管理用户" prop="subordinateUserId"> <ElFormItem label="下级用户" prop="subordinateUserId">
<ElSelect <ElSelect
v-model="model.subordinateUserId" v-model="model.subordinateUserId"
class="w-full" class="w-full"
placeholder="请选择被管理用户" placeholder="请选择下级用户"
filterable filterable
:disabled="isEdit" :disabled="isEdit"
> >

View File

@@ -3,7 +3,7 @@
* 用户管理链路搜索组件 * 用户管理链路搜索组件
* *
* 功能说明: * 功能说明:
* - 提供管理者和被管理者用户下拉选择 * - 提供上级和下级用户下拉选择
* - 支持搜索和重置操作 * - 支持搜索和重置操作
* - 与树形结构数据联动 * - 与树形结构数据联动
* *
@@ -63,11 +63,11 @@ function search() {
<template> <template>
<TableSearchPanel :model="model" :action-col-lg="8" @reset="reset" @search="search"> <TableSearchPanel :model="model" :action-col-lg="8" @reset="reset" @search="search">
<!-- <ElCol :lg="8" :md="12" :sm="12">--> <!-- <ElCol :lg="8" :md="12" :sm="12">-->
<!-- <ElFormItem label="管理者用户" prop="managerUserId">--> <!-- <ElFormItem label="上级用户" prop="managerUserId">-->
<!-- <ElSelect--> <!-- <ElSelect-->
<!-- v-model="model.managerUserId"--> <!-- v-model="model.managerUserId"-->
<!-- class="w-full"--> <!-- class="w-full"-->
<!-- placeholder="请选择管理者用户"--> <!-- placeholder="请选择上级用户"-->
<!-- clearable--> <!-- clearable-->
<!-- filterable--> <!-- filterable-->
<!-- >--> <!-- >-->

View File

@@ -771,7 +771,7 @@ onMounted(async () => {
preset="lg" preset="lg"
:show-footer="false" :show-footer="false"
max-body-height="70vh" max-body-height="70vh"
style="width: 801px" style="width: 800px"
> >
<UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType" /> <UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType" />
</BusinessFormDialog> </BusinessFormDialog>

View File

@@ -66,7 +66,7 @@ function mapUsersToCandidateUsers(users: Api.SystemManage.User[]): Api.SystemMan
return users return users
.filter(item => !item.resignedAt || item.resignedAt > now) .filter(item => !item.resignedAt || item.resignedAt > now)
.map(item => ({ .map(item => ({
id: item.id, id: String(item.id),
nickname: item.nickname?.trim() || item.username, nickname: item.nickname?.trim() || item.username,
deptId: item.deptId, deptId: item.deptId,
deptName: item.deptName ?? null deptName: item.deptName ?? null

View File

@@ -44,7 +44,7 @@ const title = computed(() => {
}); });
type Model = { type Model = {
userId: number | null; userId: string | null;
effectiveFrom: Date | null; effectiveFrom: Date | null;
effectiveUntil: Date | null; effectiveUntil: Date | null;
remark: string; remark: string;
@@ -119,7 +119,7 @@ async function handleSubmit() {
const payload: Api.SystemManage.SaveOrgLeaderRelationParams = { const payload: Api.SystemManage.SaveOrgLeaderRelationParams = {
deptId: props.dept.id, deptId: props.dept.id,
userId: Number(model.value.userId), userId: model.value.userId,
effectiveFrom, effectiveFrom,
effectiveUntil, effectiveUntil,
remark: model.value.remark.trim() || null remark: model.value.remark.trim() || null
@@ -129,10 +129,10 @@ async function handleSubmit() {
const request = const request =
isEdit.value && props.rowData isEdit.value && props.rowData
? fetchUpdateOrgLeaderRelation({ id: props.rowData.id, ...payload }) ? await fetchUpdateOrgLeaderRelation({ id: props.rowData.id, ...payload })
: fetchCreateOrgLeaderRelation(payload); : await fetchCreateOrgLeaderRelation(payload);
const { error } = await request; const { error } = request;
submitting.value = false; submitting.value = false;
@@ -186,10 +186,11 @@ watch(visible, async value => {
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem :label="$t('page.system.user.effectiveFrom')" prop="effectiveFrom"> <ElFormItem :label="$t('page.system.user.effectiveFrom')" prop="effectiveFrom" style="width: 100%">
<ElDatePicker <ElDatePicker
v-model="model.effectiveFrom" v-model="model.effectiveFrom"
class="w-full" class="w-full"
style="width: 100%"
type="datetime" type="datetime"
clearable clearable
:placeholder="$t('page.system.user.form.effectiveFrom')" :placeholder="$t('page.system.user.form.effectiveFrom')"
@@ -197,10 +198,11 @@ watch(visible, async value => {
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem :label="$t('page.system.user.effectiveUntil')" prop="effectiveUntil"> <ElFormItem :label="$t('page.system.user.effectiveUntil')" prop="effectiveUntil" style="width: 100%">
<ElDatePicker <ElDatePicker
v-model="model.effectiveUntil" v-model="model.effectiveUntil"
class="w-full" class="w-full"
style="width: 100%"
type="datetime" type="datetime"
clearable clearable
:placeholder="$t('page.system.user.form.effectiveUntil')" :placeholder="$t('page.system.user.form.effectiveUntil')"

View File

@@ -232,7 +232,7 @@ watch(visible, async value => {
<ElOption <ElOption
v-for="item in props.orgCodeOptions" v-for="item in props.orgCodeOptions"
:key="item.value" :key="item.value"
:label="item.value" :label="item.label"
:value="item.value" :value="item.value"
/> />
</ElSelect> </ElSelect>