refactor(projects): 1、优化新增 产品和新增项目;2、调整角色提示信息
This commit is contained in:
@@ -33,8 +33,6 @@ interface ProductMemberResponse {
|
|||||||
roleId: string | number;
|
roleId: string | number;
|
||||||
roleName: string;
|
roleName: string;
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
/** 多角色合并展示的非主角色名列表 */
|
|
||||||
additionalRoleNames?: string[] | null;
|
|
||||||
managerFlag: boolean;
|
managerFlag: boolean;
|
||||||
status: 0 | 1;
|
status: 0 | 1;
|
||||||
joinedTime: string;
|
joinedTime: string;
|
||||||
@@ -76,7 +74,6 @@ export function normalizeProductMember(response: ProductMemberResponse): Api.Pro
|
|||||||
roleId: normalizeStringId(response.roleId),
|
roleId: normalizeStringId(response.roleId),
|
||||||
roleName: response.roleName || '',
|
roleName: response.roleName || '',
|
||||||
roleCode: response.roleCode || '',
|
roleCode: response.roleCode || '',
|
||||||
additionalRoleNames: response.additionalRoleNames ?? [],
|
|
||||||
managerFlag: Boolean(response.managerFlag),
|
managerFlag: Boolean(response.managerFlag),
|
||||||
status: response.status,
|
status: response.status,
|
||||||
joinedTime: response.joinedTime,
|
joinedTime: response.joinedTime,
|
||||||
|
|||||||
@@ -574,6 +574,19 @@ export async function fetchCreateProductMember(id: string, data: Api.Product.Cre
|
|||||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchBatchCreateProductMembers(id: string, data: Api.Product.BatchCreateProductMembersParams) {
|
||||||
|
const result = await request<Array<string | number>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PRODUCT_PREFIX}/${id}/members/batch`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
|
||||||
|
Array.isArray(list) ? list.map(normalizeStringId) : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
|
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
|
||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
@@ -583,6 +596,15 @@ export function fetchUpdateProductMember(id: string, memberId: string, data: Api
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchBatchInactiveProductMembers(id: string, data: Api.Product.BatchInactiveProductMembersParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PRODUCT_PREFIX}/${id}/members/batch/inactive`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchInactiveProductMember(
|
export function fetchInactiveProductMember(
|
||||||
id: string,
|
id: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
|
|||||||
@@ -147,8 +147,6 @@ export interface ProjectMemberResponse {
|
|||||||
roleId: string | number;
|
roleId: string | number;
|
||||||
roleName: string;
|
roleName: string;
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
/** 多角色合并展示的非主角色名列表 */
|
|
||||||
additionalRoleNames?: string[] | null;
|
|
||||||
managerFlag: boolean;
|
managerFlag: boolean;
|
||||||
status: 0 | 1;
|
status: 0 | 1;
|
||||||
joinedTime: string;
|
joinedTime: string;
|
||||||
@@ -227,7 +225,6 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
|
|||||||
roleId: normalizeStringId(response.roleId),
|
roleId: normalizeStringId(response.roleId),
|
||||||
roleName: response.roleName || '',
|
roleName: response.roleName || '',
|
||||||
roleCode: response.roleCode || '',
|
roleCode: response.roleCode || '',
|
||||||
additionalRoleNames: response.additionalRoleNames ?? [],
|
|
||||||
managerFlag: Boolean(response.managerFlag),
|
managerFlag: Boolean(response.managerFlag),
|
||||||
status: response.status,
|
status: response.status,
|
||||||
joinedTime: response.joinedTime,
|
joinedTime: response.joinedTime,
|
||||||
|
|||||||
@@ -284,6 +284,28 @@ export function fetchInactiveProjectMember(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchBatchCreateProjectMembers(id: string, data: Api.Project.BatchCreateProjectMembersParams) {
|
||||||
|
const result = await request<Array<string | number>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/members/batch`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
|
||||||
|
Array.isArray(list) ? list.map(normalizeStringId) : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBatchInactiveProjectMembers(id: string, data: Api.Project.BatchInactiveProjectMembersParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/members/batch/inactive`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取项目设置 */
|
/** 获取项目设置 */
|
||||||
export async function fetchGetProjectSettings(id: string) {
|
export async function fetchGetProjectSettings(id: string) {
|
||||||
const result = await fetchGetProject(id);
|
const result = await fetchGetProject(id);
|
||||||
|
|||||||
30
src/typings/api/product.d.ts
vendored
30
src/typings/api/product.d.ts
vendored
@@ -99,15 +99,10 @@ declare namespace Api {
|
|||||||
userNickname: string;
|
userNickname: string;
|
||||||
/** 角色 ID */
|
/** 角色 ID */
|
||||||
roleId: string;
|
roleId: string;
|
||||||
/** 角色名称(主角色) */
|
/** 角色名称 */
|
||||||
roleName: string;
|
roleName: string;
|
||||||
/** 角色编码(主角色) */
|
/** 角色编码 */
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
/**
|
|
||||||
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
|
|
||||||
* 单角色时为空数组 [];典型场景:创建者 + 经理重合时,主行 manager,creator 名进此列表
|
|
||||||
*/
|
|
||||||
additionalRoleNames: string[];
|
|
||||||
/** 是否当前产品经理 */
|
/** 是否当前产品经理 */
|
||||||
managerFlag: boolean;
|
managerFlag: boolean;
|
||||||
/** 成员状态 */
|
/** 成员状态 */
|
||||||
@@ -215,6 +210,20 @@ declare namespace Api {
|
|||||||
previousManagerRoleId?: string | null;
|
previousManagerRoleId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增产品成员参数
|
||||||
|
*
|
||||||
|
* 刻意不复用 CreateProductMemberParams:批量接口不承担「产品经理交接」语义,
|
||||||
|
* 后端兜底拒绝 roleId 为产品经理角色的项。
|
||||||
|
*/
|
||||||
|
interface BatchCreateProductMembersParams {
|
||||||
|
members: Array<{
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 产品创建(含初始团队)原子接口参数
|
* 产品创建(含初始团队)原子接口参数
|
||||||
*
|
*
|
||||||
@@ -223,7 +232,7 @@ declare namespace Api {
|
|||||||
interface CreateProductWithTeamParams {
|
interface CreateProductWithTeamParams {
|
||||||
product: SaveProductParams;
|
product: SaveProductParams;
|
||||||
members: CreateProductMemberParams[];
|
members: CreateProductMemberParams[];
|
||||||
/** 关心人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
|
/** 关注人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
|
||||||
watcherUserIds?: string[];
|
watcherUserIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +248,11 @@ declare namespace Api {
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchInactiveProductMembersParams {
|
||||||
|
memberIds: string[];
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 产品需求相关类型定义 ==========
|
// ========== 产品需求相关类型定义 ==========
|
||||||
/** 需求状态编码 */
|
/** 需求状态编码 */
|
||||||
type RequirementStatusCode =
|
type RequirementStatusCode =
|
||||||
|
|||||||
31
src/typings/api/project.d.ts
vendored
31
src/typings/api/project.d.ts
vendored
@@ -519,15 +519,10 @@ declare namespace Api {
|
|||||||
userNickname: string;
|
userNickname: string;
|
||||||
/** 角色 ID */
|
/** 角色 ID */
|
||||||
roleId: string;
|
roleId: string;
|
||||||
/** 角色名称(主角色) */
|
/** 角色名称 */
|
||||||
roleName: string;
|
roleName: string;
|
||||||
/** 角色编码(主角色) */
|
/** 角色编码 */
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
/**
|
|
||||||
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
|
|
||||||
* 单角色时为空数组 [];典型场景:创建者 + 负责人重合时,主行 manager,creator 名进此列表
|
|
||||||
*/
|
|
||||||
additionalRoleNames: string[];
|
|
||||||
/** 是否项目负责人 */
|
/** 是否项目负责人 */
|
||||||
managerFlag: boolean;
|
managerFlag: boolean;
|
||||||
/** 成员状态 */
|
/** 成员状态 */
|
||||||
@@ -625,6 +620,26 @@ declare namespace Api {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增项目成员参数
|
||||||
|
*
|
||||||
|
* 刻意不复用 CreateProjectMemberParams:批量接口不承担"项目负责人交接"语义,
|
||||||
|
* 后端兜底拒绝 roleId 为项目负责人角色的项。
|
||||||
|
*/
|
||||||
|
interface BatchCreateProjectMembersParams {
|
||||||
|
members: Array<{
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量移出项目成员参数 */
|
||||||
|
interface BatchInactiveProjectMembersParams {
|
||||||
|
memberIds: string[];
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目创建(含初始团队)原子接口参数
|
* 项目创建(含初始团队)原子接口参数
|
||||||
*
|
*
|
||||||
@@ -633,7 +648,7 @@ declare namespace Api {
|
|||||||
interface CreateProjectWithTeamParams {
|
interface CreateProjectWithTeamParams {
|
||||||
project: SaveProjectParams;
|
project: SaveProjectParams;
|
||||||
members: CreateProjectMemberParams[];
|
members: CreateProjectMemberParams[];
|
||||||
/** 关心人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */
|
/** 关注人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */
|
||||||
watcherUserIds?: string[];
|
watcherUserIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
src/typings/api/system-manage.d.ts
vendored
4
src/typings/api/system-manage.d.ts
vendored
@@ -47,6 +47,8 @@ declare namespace Api {
|
|||||||
type: RoleType;
|
type: RoleType;
|
||||||
/** remark */
|
/** remark */
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
|
/** 是否在前端选择面板可见:0 不可见 / 1 可见,缺省视作可见 */
|
||||||
|
visible?: 0 | 1 | null;
|
||||||
/** create time */
|
/** create time */
|
||||||
createTime: number;
|
createTime: number;
|
||||||
}
|
}
|
||||||
@@ -226,7 +228,7 @@ declare namespace Api {
|
|||||||
|
|
||||||
type PostList = PageResult<Post>;
|
type PostList = PageResult<Post>;
|
||||||
|
|
||||||
type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort'>;
|
type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort' | 'remark' | 'visible'>;
|
||||||
|
|
||||||
type RoleSimpleList = RoleSimple[];
|
type RoleSimpleList = RoleSimple[];
|
||||||
|
|
||||||
|
|||||||
4
src/typings/components.d.ts
vendored
4
src/typings/components.d.ts
vendored
@@ -100,6 +100,10 @@ declare module 'vue' {
|
|||||||
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
||||||
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
||||||
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
||||||
|
'IconEp:box': typeof import('~icons/ep/box')['default']
|
||||||
|
'IconEp:files': typeof import('~icons/ep/files')['default']
|
||||||
|
'IconEp:infoFilled': typeof import('~icons/ep/info-filled')['default']
|
||||||
|
'IconEp:plus': typeof import('~icons/ep/plus')['default']
|
||||||
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
||||||
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
||||||
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, reactive, watch } from 'vue';
|
import { computed, nextTick, reactive, watch } from 'vue';
|
||||||
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
|
|
||||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ProductCreateTeamMemberDialog' });
|
defineOptions({ name: 'ProductCreateTeamMemberDialog' });
|
||||||
|
|
||||||
type OperateMode = 'create' | 'edit';
|
|
||||||
|
|
||||||
interface DraftMemberInput {
|
interface DraftMemberInput {
|
||||||
userId: string;
|
userId: string;
|
||||||
roleId: string;
|
roleId: string;
|
||||||
@@ -16,22 +12,16 @@ interface DraftMemberInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: OperateMode;
|
|
||||||
initial: DraftMemberInput | null;
|
initial: DraftMemberInput | null;
|
||||||
userOptions: Api.SystemManage.UserSimple[];
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
roleOptions: Api.SystemManage.RoleSimple[];
|
roleOptions: Api.SystemManage.RoleSimple[];
|
||||||
/** 已使用且不可选的 userId(编辑模式应当排除当前行自身) */
|
|
||||||
disabledUserIds?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'submit', payload: DraftMemberInput): void;
|
(e: 'submit', payload: DraftMemberInput): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = defineProps<Props>();
|
||||||
disabledUserIds: () => []
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
const visible = defineModel<boolean>('visible', { default: false });
|
const visible = defineModel<boolean>('visible', { default: false });
|
||||||
@@ -39,31 +29,21 @@ const visible = defineModel<boolean>('visible', { default: false });
|
|||||||
const { formRef, validate } = useForm();
|
const { formRef, validate } = useForm();
|
||||||
const { createRequiredRule } = useFormRules();
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
const model = reactive<DraftMemberInput>({
|
const model = reactive<DraftMemberInput>({ userId: '', roleId: '', remark: '' });
|
||||||
userId: '',
|
|
||||||
roleId: '',
|
|
||||||
remark: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
|
|
||||||
|
|
||||||
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
||||||
|
|
||||||
|
const selectableRoles = computed(() => props.roleOptions.filter(role => role.visible !== 0));
|
||||||
|
|
||||||
const rules = computed(
|
const rules = computed(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
|
|
||||||
roleId: [createRequiredRule('请选择角色')]
|
roleId: [createRequiredRule('请选择角色')]
|
||||||
}) satisfies Record<string, App.Global.FormRule[]>
|
}) satisfies Record<string, App.Global.FormRule[]>
|
||||||
);
|
);
|
||||||
|
|
||||||
function isManagerRole(role: Api.SystemManage.RoleSimple) {
|
|
||||||
return role.code === PRODUCT_MANAGER_ROLE_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
await validate();
|
await validate();
|
||||||
|
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
userId: model.userId,
|
userId: model.userId,
|
||||||
roleId: model.roleId,
|
roleId: model.roleId,
|
||||||
@@ -72,34 +52,21 @@ async function handleConfirm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(visible, async value => {
|
watch(visible, async value => {
|
||||||
if (!value) {
|
if (!value) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
model.userId = props.initial?.userId || '';
|
model.userId = props.initial?.userId || '';
|
||||||
model.roleId = props.initial?.roleId || '';
|
model.roleId = props.initial?.roleId || '';
|
||||||
model.remark = props.initial?.remark || '';
|
model.remark = props.initial?.remark || '';
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm">
|
<BusinessFormDialog v-model="visible" title="调整成员角色" preset="sm" @confirm="handleConfirm">
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
<ElFormItem label="成员用户">
|
||||||
<BusinessUserSelect
|
|
||||||
v-model="model.userId"
|
|
||||||
:options="userOptions"
|
|
||||||
:disabled-user-ids="disabledUserIds"
|
|
||||||
disabled-label="已添加"
|
|
||||||
placeholder="请选择成员用户"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem v-else label="成员用户">
|
|
||||||
<ElInput
|
<ElInput
|
||||||
:model-value="userLabelMap.get(String(model.userId)) || ''"
|
:model-value="userLabelMap.get(String(model.userId)) || ''"
|
||||||
readonly
|
readonly
|
||||||
@@ -111,17 +78,13 @@ watch(visible, async value => {
|
|||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="目标角色" prop="roleId">
|
<ElFormItem label="目标角色" prop="roleId">
|
||||||
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
||||||
<ElOption
|
<ElOption v-for="role in selectableRoles" :key="role.id" :label="role.name" :value="role.id">
|
||||||
v-for="role in roleOptions"
|
<div class="product-create-team-member-dialog__role-option">
|
||||||
:key="role.id"
|
<span class="product-create-team-member-dialog__role-option-name">{{ role.name }}</span>
|
||||||
:label="role.name"
|
<ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
|
||||||
:value="role.id"
|
<icon-ep:info-filled class="product-create-team-member-dialog__role-option-info" @click.stop />
|
||||||
:disabled="isManagerRole(role)"
|
</ElTooltip>
|
||||||
>
|
</div>
|
||||||
<span>{{ role.name }}</span>
|
|
||||||
<span v-if="isManagerRole(role)" class="product-create-team-member-dialog__role-hint">
|
|
||||||
(已由第 1 步指定)
|
|
||||||
</span>
|
|
||||||
</ElOption>
|
</ElOption>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
@@ -144,12 +107,6 @@ watch(visible, async value => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.product-create-team-member-dialog__role-hint {
|
|
||||||
margin-left: 8px;
|
|
||||||
color: rgb(148 163 184 / 96%);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.product-create-team-member-dialog__readonly-input .el-input__wrapper) {
|
:deep(.product-create-team-member-dialog__readonly-input .el-input__wrapper) {
|
||||||
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
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;
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
@@ -166,4 +123,31 @@ watch(visible, async value => {
|
|||||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-create-team-member-dialog__role-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-team-member-dialog__role-option-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-team-member-dialog__role-option-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-team-member-dialog__role-option-info:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
|
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
|
||||||
import { fetchGetRoleSimpleList } from '@/service/api';
|
import ProductTeamBatchDialog, {
|
||||||
import { getProductTeamTableHeight } from '../../setting/shared';
|
type BatchMemberPayload
|
||||||
|
} from '@/views/product/shared/components/product-team-batch-dialog.vue';
|
||||||
import ProductCreateTeamMemberDialog from './product-create-team-member-dialog.vue';
|
import ProductCreateTeamMemberDialog from './product-create-team-member-dialog.vue';
|
||||||
import type { ProductCreateBaseForm } from './product-create-base-form.vue';
|
import type { ProductCreateBaseForm } from './product-create-base-form.vue';
|
||||||
|
|
||||||
@@ -21,57 +22,34 @@ interface DraftMember {
|
|||||||
interface Props {
|
interface Props {
|
||||||
baseInfo: ProductCreateBaseForm;
|
baseInfo: ProductCreateBaseForm;
|
||||||
userOptions: Api.SystemManage.UserSimple[];
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
roleOptions: Api.SystemManage.RoleSimple[];
|
||||||
|
roleLoading: boolean;
|
||||||
|
managerRoleError: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:members', members: Api.Product.CreateProductMemberParams[]): void;
|
(e: 'update:members', members: Api.Product.CreateProductMemberParams[]): void;
|
||||||
(e: 'update:watcherUserIds', watcherUserIds: string[]): void;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
|
||||||
const roleLoading = ref(false);
|
|
||||||
const managerRoleError = ref('');
|
|
||||||
const members = ref<DraftMember[]>([]);
|
const members = ref<DraftMember[]>([]);
|
||||||
|
|
||||||
const memberDialogVisible = ref(false);
|
const memberDialogVisible = ref(false);
|
||||||
const memberDialogMode = ref<'create' | 'edit'>('create');
|
|
||||||
const editingKey = ref<string | null>(null);
|
const editingKey = ref<string | null>(null);
|
||||||
|
|
||||||
const watcherUserIds = ref<string[]>([]);
|
const batchDialogVisible = ref(false);
|
||||||
|
|
||||||
// 关心人候选用户:排除已在团队成员列表中的用户(包含产品经理本人)
|
const batchDisabledUserIds = computed(() => members.value.map(item => item.userId).filter(Boolean));
|
||||||
const watcherUserOptions = computed(() => {
|
|
||||||
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
|
|
||||||
return props.userOptions.filter(user => !memberUserIds.has(user.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamTableHeight = getProductTeamTableHeight(4);
|
|
||||||
|
|
||||||
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
||||||
|
|
||||||
const managerRole = computed(() => roleOptions.value.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
|
const managerRole = computed(() => props.roleOptions.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
|
||||||
|
|
||||||
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
|
|
||||||
const dialogDisabledUserIds = computed(() => {
|
|
||||||
return members.value
|
|
||||||
.filter(item => !editingKey.value || item.key !== editingKey.value)
|
|
||||||
.map(item => item.userId)
|
|
||||||
.filter(Boolean);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialogInitial = computed(() => {
|
const dialogInitial = computed(() => {
|
||||||
if (memberDialogMode.value === 'create' || !editingKey.value) {
|
if (!editingKey.value) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = members.value.find(item => item.key === editingKey.value);
|
const target = members.value.find(item => item.key === editingKey.value);
|
||||||
|
if (!target) return null;
|
||||||
if (!target) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
|
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,31 +58,13 @@ function getUserNickname(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRoleName(roleId: string) {
|
function getRoleName(roleId: string) {
|
||||||
return roleOptions.value.find(item => item.id === roleId)?.name || '--';
|
return props.roleOptions.find(item => item.id === roleId)?.name || '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateKey() {
|
function generateKey() {
|
||||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoles() {
|
|
||||||
roleLoading.value = true;
|
|
||||||
managerRoleError.value = '';
|
|
||||||
|
|
||||||
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'product' });
|
|
||||||
|
|
||||||
roleLoading.value = false;
|
|
||||||
|
|
||||||
roleOptions.value = data ?? [];
|
|
||||||
|
|
||||||
if (!managerRole.value) {
|
|
||||||
managerRoleError.value = '未找到产品经理角色,请联系管理员';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshManagerRow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshManagerRow() {
|
function refreshManagerRow() {
|
||||||
const managerUserId = props.baseInfo.managerUserId;
|
const managerUserId = props.baseInfo.managerUserId;
|
||||||
|
|
||||||
@@ -132,14 +92,25 @@ function refreshManagerRow() {
|
|||||||
emitMembers();
|
emitMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openBatch() {
|
||||||
memberDialogMode.value = 'create';
|
batchDialogVisible.value = true;
|
||||||
editingKey.value = null;
|
}
|
||||||
memberDialogVisible.value = true;
|
|
||||||
|
function handleBatchSubmit(payloads: BatchMemberPayload[]) {
|
||||||
|
for (const p of payloads) {
|
||||||
|
members.value.push({
|
||||||
|
key: generateKey(),
|
||||||
|
userId: p.userId,
|
||||||
|
roleId: p.roleId,
|
||||||
|
remark: p.remark,
|
||||||
|
locked: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchDialogVisible.value = false;
|
||||||
|
emitMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: DraftMember) {
|
function openEdit(row: DraftMember) {
|
||||||
memberDialogMode.value = 'edit';
|
|
||||||
editingKey.value = row.key;
|
editingKey.value = row.key;
|
||||||
memberDialogVisible.value = true;
|
memberDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -149,27 +120,16 @@ function removeMember(key: string) {
|
|||||||
emitMembers();
|
emitMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
|
function handleMemberEditSubmit(payload: { userId: string; roleId: string; remark: string }) {
|
||||||
if (memberDialogMode.value === 'create') {
|
if (!editingKey.value) return;
|
||||||
members.value.push({
|
const idx = members.value.findIndex(item => item.key === editingKey.value);
|
||||||
key: generateKey(),
|
if (idx >= 0) {
|
||||||
userId: payload.userId,
|
members.value[idx] = {
|
||||||
|
...members.value[idx],
|
||||||
roleId: payload.roleId,
|
roleId: payload.roleId,
|
||||||
remark: payload.remark,
|
remark: payload.remark
|
||||||
locked: false
|
};
|
||||||
});
|
|
||||||
} else if (editingKey.value) {
|
|
||||||
const idx = members.value.findIndex(item => item.key === editingKey.value);
|
|
||||||
|
|
||||||
if (idx >= 0) {
|
|
||||||
members.value[idx] = {
|
|
||||||
...members.value[idx],
|
|
||||||
roleId: payload.roleId,
|
|
||||||
remark: payload.remark
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
memberDialogVisible.value = false;
|
memberDialogVisible.value = false;
|
||||||
emitMembers();
|
emitMembers();
|
||||||
}
|
}
|
||||||
@@ -188,8 +148,8 @@ function emitMembers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runValidate(): Promise<boolean> {
|
async function runValidate(): Promise<boolean> {
|
||||||
if (managerRoleError.value) {
|
if (props.managerRoleError) {
|
||||||
window.$message?.error(managerRoleError.value);
|
window.$message?.error(props.managerRoleError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,43 +174,32 @@ async function runValidate(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWatcherChange(ids: string[]) {
|
|
||||||
watcherUserIds.value = ids;
|
|
||||||
emit('update:watcherUserIds', ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 团队成员变化时,剔除已被加入团队的关心人,避免重叠
|
|
||||||
watch(
|
|
||||||
() => members.value.map(item => item.userId).join(','),
|
|
||||||
() => {
|
|
||||||
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
|
|
||||||
const filtered = watcherUserIds.value.filter(id => !memberUserIds.has(id));
|
|
||||||
|
|
||||||
if (filtered.length !== watcherUserIds.value.length) {
|
|
||||||
handleWatcherChange(filtered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(loadRoles);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.baseInfo.managerUserId,
|
() => props.baseInfo.managerUserId,
|
||||||
() => {
|
() => {
|
||||||
if (!managerRoleError.value && managerRole.value) {
|
if (!props.managerRoleError && managerRole.value) {
|
||||||
refreshManagerRow();
|
refreshManagerRow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// roleOptions 异步加载到位后,补一次 locked 行刷新
|
||||||
|
watch(managerRole, () => {
|
||||||
|
if (!props.managerRoleError && managerRole.value && props.baseInfo.managerUserId) {
|
||||||
|
refreshManagerRow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
defineExpose({ validate: runValidate });
|
defineExpose({ validate: runValidate });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="roleLoading" class="team-step">
|
<div v-loading="roleLoading" class="team-step">
|
||||||
<div class="team-step__toolbar">
|
<button type="button" class="team-step__add" :disabled="Boolean(managerRoleError)" @click="openBatch">
|
||||||
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
|
<icon-ep:plus class="team-step__add-icon" />
|
||||||
</div>
|
<span>新增成员</span>
|
||||||
|
<span class="team-step__add-hint">从部门 / 管理链路 / 全公司 批量选人</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<ElAlert
|
<ElAlert
|
||||||
v-if="managerRoleError"
|
v-if="managerRoleError"
|
||||||
@@ -261,62 +210,49 @@ defineExpose({ validate: runValidate });
|
|||||||
class="team-step__alert"
|
class="team-step__alert"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角“新增成员”添加">
|
<div class="team-step__table-wrap">
|
||||||
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
<ElTable :data="members" height="100%" border row-key="key" empty-text="点击右上角“新增成员”添加">
|
||||||
<ElTableColumn label="成员姓名" min-width="120">
|
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="成员姓名" min-width="120">
|
||||||
{{ getUserNickname(row.userId) }}
|
<template #default="{ row }">
|
||||||
</template>
|
{{ getUserNickname(row.userId) }}
|
||||||
</ElTableColumn>
|
</template>
|
||||||
<ElTableColumn label="当前角色" min-width="140">
|
</ElTableColumn>
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="当前角色" min-width="140">
|
||||||
{{ getRoleName(row.roleId) }}
|
<template #default="{ row }">
|
||||||
</template>
|
{{ getRoleName(row.roleId) }}
|
||||||
</ElTableColumn>
|
</template>
|
||||||
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
|
</ElTableColumn>
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
|
||||||
{{ row.remark || '--' }}
|
<template #default="{ row }">
|
||||||
</template>
|
{{ row.remark || '--' }}
|
||||||
</ElTableColumn>
|
</template>
|
||||||
<ElTableColumn label="操作" width="150" fixed="right" align="center">
|
</ElTableColumn>
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="操作" width="150" fixed="right" align="center">
|
||||||
<div class="team-step__actions">
|
<template #default="{ row }">
|
||||||
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
|
<div class="team-step__actions">
|
||||||
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
|
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
|
||||||
</div>
|
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
|
||||||
</template>
|
</div>
|
||||||
</ElTableColumn>
|
</template>
|
||||||
</ElTable>
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
<div class="watcher-row">
|
|
||||||
<span class="watcher-row__label">
|
|
||||||
关心人
|
|
||||||
<span class="watcher-row__optional">(选填)</span>
|
|
||||||
</span>
|
|
||||||
<ElSelect
|
|
||||||
:model-value="watcherUserIds"
|
|
||||||
multiple
|
|
||||||
filterable
|
|
||||||
clearable
|
|
||||||
collapse-tags
|
|
||||||
collapse-tags-tooltip
|
|
||||||
:max-collapse-tags="3"
|
|
||||||
placeholder="可在列表 / 概览看到此产品的关注人"
|
|
||||||
class="watcher-row__select"
|
|
||||||
@update:model-value="handleWatcherChange"
|
|
||||||
>
|
|
||||||
<ElOption v-for="item in watcherUserOptions" :key="item.id" :value="item.id" :label="item.nickname" />
|
|
||||||
</ElSelect>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductCreateTeamMemberDialog
|
<ProductCreateTeamMemberDialog
|
||||||
v-model:visible="memberDialogVisible"
|
v-model:visible="memberDialogVisible"
|
||||||
:mode="memberDialogMode"
|
|
||||||
:initial="dialogInitial"
|
:initial="dialogInitial"
|
||||||
:user-options="userOptions"
|
:user-options="userOptions"
|
||||||
:role-options="roleOptions"
|
:role-options="roleOptions"
|
||||||
:disabled-user-ids="dialogDisabledUserIds"
|
@submit="handleMemberEditSubmit"
|
||||||
@submit="handleMemberSubmit"
|
/>
|
||||||
|
|
||||||
|
<ProductTeamBatchDialog
|
||||||
|
v-model:visible="batchDialogVisible"
|
||||||
|
:user-options="userOptions"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:disabled-user-ids="batchDisabledUserIds"
|
||||||
|
@submit="handleBatchSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -326,16 +262,65 @@ defineExpose({ validate: runValidate });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-step__toolbar {
|
.team-step__add {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px dashed var(--el-border-color-darker);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add:hover:not(:disabled) {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add-hint {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add:hover:not(:disabled) .team-step__add-hint {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-step__alert {
|
.team-step__alert {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__table-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-step__actions {
|
.team-step__actions {
|
||||||
@@ -343,27 +328,4 @@ defineExpose({ validate: runValidate });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watcher-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watcher-row__label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgb(60 70 95 / 96%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.watcher-row__optional {
|
|
||||||
color: rgb(140 150 170 / 96%);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watcher-row__select {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
|
||||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||||
import { fetchCreateProductWithTeam, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
|
import { fetchCreateProductWithTeam, fetchGetProduct, fetchGetRoleSimpleList, fetchUpdateProduct } from '@/service/api';
|
||||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
import DictSelect from '@/components/custom/dict-select.vue';
|
import DictSelect from '@/components/custom/dict-select.vue';
|
||||||
@@ -112,9 +113,29 @@ const baseFormRef = ref<InstanceType<typeof ProductCreateBaseForm> | null>(null)
|
|||||||
const teamStepRef = ref<InstanceType<typeof ProductCreateTeamStep> | null>(null);
|
const teamStepRef = ref<InstanceType<typeof ProductCreateTeamStep> | null>(null);
|
||||||
const currentStep = ref<1 | 2>(1);
|
const currentStep = ref<1 | 2>(1);
|
||||||
|
|
||||||
|
// === 新增模式:角色列表(父级加载,下发给 team-step 与批量弹层) ===
|
||||||
|
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||||
|
const roleLoading = ref(false);
|
||||||
|
const managerRoleError = ref('');
|
||||||
|
|
||||||
|
const managerRole = computed(() => roleOptions.value.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
roleLoading.value = true;
|
||||||
|
managerRoleError.value = '';
|
||||||
|
|
||||||
|
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'product' });
|
||||||
|
|
||||||
|
roleLoading.value = false;
|
||||||
|
roleOptions.value = data ?? [];
|
||||||
|
|
||||||
|
if (!managerRole.value) {
|
||||||
|
managerRoleError.value = '未找到产品经理角色,请联系管理员';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createBaseModel = ref<ProductCreateBaseFormModel>(createBaseInfo());
|
const createBaseModel = ref<ProductCreateBaseFormModel>(createBaseInfo());
|
||||||
const draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]);
|
const draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]);
|
||||||
const draftWatcherUserIds = ref<string[]>([]);
|
|
||||||
|
|
||||||
function createBaseInfo(): ProductCreateBaseFormModel {
|
function createBaseInfo(): ProductCreateBaseFormModel {
|
||||||
return { code: '', name: '', directionCode: '', managerUserId: null, description: '' };
|
return { code: '', name: '', directionCode: '', managerUserId: null, description: '' };
|
||||||
@@ -158,8 +179,7 @@ async function handleCreateSubmit() {
|
|||||||
managerUserId: createBaseModel.value.managerUserId as string,
|
managerUserId: createBaseModel.value.managerUserId as string,
|
||||||
description: getNullableText(createBaseModel.value.description)
|
description: getNullableText(createBaseModel.value.description)
|
||||||
},
|
},
|
||||||
members: draftMembers.value,
|
members: draftMembers.value
|
||||||
watcherUserIds: draftWatcherUserIds.value.length > 0 ? draftWatcherUserIds.value : undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { error, data } = await fetchCreateProductWithTeam(payload);
|
const { error, data } = await fetchCreateProductWithTeam(payload);
|
||||||
@@ -188,8 +208,8 @@ watch(visible, async value => {
|
|||||||
editModel.value = createEditModel();
|
editModel.value = createEditModel();
|
||||||
createBaseModel.value = createBaseInfo();
|
createBaseModel.value = createBaseInfo();
|
||||||
draftMembers.value = [];
|
draftMembers.value = [];
|
||||||
draftWatcherUserIds.value = [];
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
await loadRoles();
|
||||||
editFormRef.value?.clearValidate();
|
editFormRef.value?.clearValidate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -295,7 +315,7 @@ watch(visible, async value => {
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
</BusinessFormDialog>
|
</BusinessFormDialog>
|
||||||
|
|
||||||
<!-- 新增模式:两步向导(复合内容特例,自定义 ElDialog 880px) -->
|
<!-- 新增模式:两步向导(复合内容特例,1080px,左侧概念区 + 右侧步骤面板) -->
|
||||||
<ElDialog
|
<ElDialog
|
||||||
v-else
|
v-else
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
@@ -304,43 +324,85 @@ watch(visible, async value => {
|
|||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
align-center
|
align-center
|
||||||
width="760px"
|
width="1080px"
|
||||||
>
|
>
|
||||||
<div class="product-create-dialog__stepbar">
|
<div class="product-create-dialog__split">
|
||||||
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }">
|
<aside class="product-create-dialog__guide">
|
||||||
<span class="product-create-dialog__step-index">1</span>
|
<div class="product-create-dialog__guide-hero">
|
||||||
<span class="product-create-dialog__step-text">
|
<div class="product-create-dialog__guide-hero-icon">
|
||||||
<strong>基础资料</strong>
|
<icon-ep:box />
|
||||||
<small>定义产品身份和负责人</small>
|
</div>
|
||||||
</span>
|
<div class="product-create-dialog__guide-hero-text">
|
||||||
</div>
|
<div class="product-create-dialog__guide-hero-title">产品</div>
|
||||||
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
|
<div class="product-create-dialog__guide-hero-sub">需求 · 项目 · 迭代 的承载单元</div>
|
||||||
<span class="product-create-dialog__step-index">2</span>
|
</div>
|
||||||
<span class="product-create-dialog__step-text">
|
</div>
|
||||||
<strong>初始化团队</strong>
|
|
||||||
<small>配置对象域成员角色</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="product-create-dialog__body">
|
<p class="product-create-dialog__guide-lead">
|
||||||
<div v-show="currentStep === 1" class="product-create-dialog__panel">
|
产品与需求池管理,提供多维度的需求规划工具,打通客户、业务团队和产研团队之间的协作。
|
||||||
<ProductCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" />
|
</p>
|
||||||
</div>
|
|
||||||
<div v-show="currentStep === 2" class="product-create-dialog__panel">
|
<section class="product-create-dialog__guide-section">
|
||||||
<ProductCreateTeamStep
|
<h4>包含</h4>
|
||||||
ref="teamStepRef"
|
<p>需求、变更、迭代、模块、文档、状态、统计报表。</p>
|
||||||
:base-info="createBaseModel"
|
</section>
|
||||||
:user-options="managerUserOptions"
|
|
||||||
@update:members="draftMembers = $event"
|
<section class="product-create-dialog__guide-section">
|
||||||
@update:watcher-user-ids="draftWatcherUserIds = $event"
|
<h4>参与人</h4>
|
||||||
/>
|
<p>产品经理(必填,创建后锁定) · 团队角色(业务专员 / 游客 / 关注人)。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="product-create-dialog__guide-section">
|
||||||
|
<h4>命名建议</h4>
|
||||||
|
<p>建议使用业务团队约定俗成的简短名称,产品创建后不再轻易调整,会影响下游引用。</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="product-create-dialog__main">
|
||||||
|
<div class="product-create-dialog__stepbar">
|
||||||
|
<div
|
||||||
|
class="product-create-dialog__step"
|
||||||
|
:class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }"
|
||||||
|
>
|
||||||
|
<span class="product-create-dialog__step-index">1</span>
|
||||||
|
<span class="product-create-dialog__step-text">
|
||||||
|
<strong>基础资料</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
|
||||||
|
<span class="product-create-dialog__step-index">2</span>
|
||||||
|
<span class="product-create-dialog__step-text">
|
||||||
|
<strong>初始化团队</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-create-dialog__body">
|
||||||
|
<div v-show="currentStep === 1" class="product-create-dialog__panel">
|
||||||
|
<ProductCreateBaseForm
|
||||||
|
ref="baseFormRef"
|
||||||
|
v-model="createBaseModel"
|
||||||
|
:manager-user-options="managerUserOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="currentStep === 2" class="product-create-dialog__panel">
|
||||||
|
<ProductCreateTeamStep
|
||||||
|
ref="teamStepRef"
|
||||||
|
:base-info="createBaseModel"
|
||||||
|
:user-options="managerUserOptions"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:role-loading="roleLoading"
|
||||||
|
:manager-role-error="managerRoleError"
|
||||||
|
@update:members="draftMembers = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="product-create-dialog__footer">
|
<div class="product-create-dialog__footer">
|
||||||
<span class="product-create-dialog__footer-meta">第 {{ currentStep }} 步,共 2 步</span>
|
<span class="product-create-dialog__footer-meta">第 {{ currentStep }} 步,共 2 步</span>
|
||||||
<ElSpace :size="10">
|
<ElSpace :size="10">
|
||||||
<ElButton @click="closeDialog">取消</ElButton>
|
<ElButton @click="closeDialog">取消</ElButton>
|
||||||
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
|
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
|
||||||
@@ -378,6 +440,92 @@ watch(visible, async value => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide {
|
||||||
|
padding: 28px 24px;
|
||||||
|
background: linear-gradient(180deg, #f7f9fc 0%, #fafbfc 100%);
|
||||||
|
border-right: 1px solid rgb(229 233 242 / 96%);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: min(720px, calc(100vh - 160px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid rgb(229 233 242 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-hero-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-hero-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-hero-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-hero-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-lead {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-section + .product-create-dialog__guide-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-section h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__guide-section p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-dialog__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.product-create-dialog__stepbar {
|
.product-create-dialog__stepbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -433,13 +581,15 @@ watch(visible, async value => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-create-dialog__body {
|
.product-create-dialog__body {
|
||||||
min-height: 0;
|
height: min(520px, calc(100vh - 240px));
|
||||||
max-height: min(560px, calc(100vh - 240px));
|
overflow: hidden;
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-create-dialog__panel {
|
.product-create-dialog__panel {
|
||||||
|
height: 100%;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-create-dialog__footer {
|
.product-create-dialog__footer {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useMediaQuery } from '@vueuse/core';
|
|||||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||||
import {
|
import {
|
||||||
|
fetchBatchCreateProductMembers,
|
||||||
|
fetchBatchInactiveProductMembers,
|
||||||
fetchChangeProductStatus,
|
fetchChangeProductStatus,
|
||||||
fetchCreateProductMember,
|
fetchCreateProductMember,
|
||||||
fetchDeleteProduct,
|
fetchDeleteProduct,
|
||||||
@@ -19,8 +21,12 @@ import { useAuthStore } from '@/store/modules/auth';
|
|||||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
|
import ProductTeamBatchDialog, {
|
||||||
|
type BatchMemberPayload
|
||||||
|
} from '@/views/product/shared/components/product-team-batch-dialog.vue';
|
||||||
import { useCurrentProduct } from '../shared/use-current-product';
|
import { useCurrentProduct } from '../shared/use-current-product';
|
||||||
import BaseInfoDialog from './modules/base-info-dialog.vue';
|
import BaseInfoDialog from './modules/base-info-dialog.vue';
|
||||||
|
import MemberBatchRemoveDialog from './modules/member-batch-remove-dialog.vue';
|
||||||
import MemberOperateDialog from './modules/member-operate-dialog.vue';
|
import MemberOperateDialog from './modules/member-operate-dialog.vue';
|
||||||
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
|
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
|
||||||
import ProductDeleteDialog from './modules/product-delete-dialog.vue';
|
import ProductDeleteDialog from './modules/product-delete-dialog.vue';
|
||||||
@@ -70,7 +76,11 @@ const pageLoading = ref(false);
|
|||||||
const memberLoading = ref(false);
|
const memberLoading = ref(false);
|
||||||
const baseInfoVisible = ref(false);
|
const baseInfoVisible = ref(false);
|
||||||
const memberOperateVisible = ref(false);
|
const memberOperateVisible = ref(false);
|
||||||
|
const memberBatchVisible = ref(false);
|
||||||
const memberRemoveVisible = ref(false);
|
const memberRemoveVisible = ref(false);
|
||||||
|
const memberBatchRemoveVisible = ref(false);
|
||||||
|
const teamPanelRef = ref<InstanceType<typeof SettingTeamPanel> | null>(null);
|
||||||
|
const selectedBatchRemoveMembers = ref<Api.Product.ProductMember[]>([]);
|
||||||
const statusActionVisible = ref(false);
|
const statusActionVisible = ref(false);
|
||||||
const deleteVisible = ref(false);
|
const deleteVisible = ref(false);
|
||||||
const memberOperateMode = ref<'create' | 'edit'>('create');
|
const memberOperateMode = ref<'create' | 'edit'>('create');
|
||||||
@@ -217,9 +227,7 @@ function scrollToSection(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateMember() {
|
function openCreateMember() {
|
||||||
memberOperateMode.value = 'create';
|
memberBatchVisible.value = true;
|
||||||
selectedMember.value = null;
|
|
||||||
memberOperateVisible.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditMember(member: Api.Product.ProductMember) {
|
function openEditMember(member: Api.Product.ProductMember) {
|
||||||
@@ -233,6 +241,12 @@ function openRemoveMember(member: Api.Product.ProductMember) {
|
|||||||
memberRemoveVisible.value = true;
|
memberRemoveVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBatchRemoveMember(targetMembers: Api.Product.ProductMember[]) {
|
||||||
|
if (!targetMembers.length) return;
|
||||||
|
selectedBatchRemoveMembers.value = targetMembers;
|
||||||
|
memberBatchRemoveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
|
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
|
||||||
selectedAction.value = action;
|
selectedAction.value = action;
|
||||||
statusActionVisible.value = true;
|
statusActionVisible.value = true;
|
||||||
@@ -288,6 +302,29 @@ async function handleSubmitMemberOperate(event: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSubmitMemberBatch(payloads: BatchMemberPayload[]) {
|
||||||
|
if (!currentObjectId.value || !payloads.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await fetchBatchCreateProductMembers(currentObjectId.value, {
|
||||||
|
members: payloads.map(item => ({
|
||||||
|
userId: item.userId,
|
||||||
|
roleId: item.roleId,
|
||||||
|
remark: item.remark.trim() || null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success(`已新增 ${payloads.length} 名成员`);
|
||||||
|
memberBatchVisible.value = false;
|
||||||
|
|
||||||
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
|
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
|
||||||
if (!currentObjectId.value || !selectedMember.value?.id) {
|
if (!currentObjectId.value || !selectedMember.value?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -305,6 +342,30 @@ async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemb
|
|||||||
await Promise.all([loadMembers(), loadSettings()]);
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSubmitBatchRemoveMember(payload: { reason: string | null }) {
|
||||||
|
if (!currentObjectId.value || !selectedBatchRemoveMembers.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = selectedBatchRemoveMembers.value.map(item => item.id).filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
if (!memberIds.length) return;
|
||||||
|
|
||||||
|
const { error } = await fetchBatchInactiveProductMembers(currentObjectId.value, {
|
||||||
|
memberIds,
|
||||||
|
reason: payload.reason
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
window.$message?.success(`已移出 ${memberIds.length} 名成员`);
|
||||||
|
memberBatchRemoveVisible.value = false;
|
||||||
|
selectedBatchRemoveMembers.value = [];
|
||||||
|
teamPanelRef.value?.clearSelection();
|
||||||
|
|
||||||
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
|
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
|
||||||
if (!currentObjectId.value || !selectedAction.value) {
|
if (!currentObjectId.value || !selectedAction.value) {
|
||||||
return;
|
return;
|
||||||
@@ -393,6 +454,7 @@ watch(
|
|||||||
|
|
||||||
<section :id="sectionIdMap.team" class="product-setting-page__section">
|
<section :id="sectionIdMap.team" class="product-setting-page__section">
|
||||||
<SettingTeamPanel
|
<SettingTeamPanel
|
||||||
|
ref="teamPanelRef"
|
||||||
:members="members"
|
:members="members"
|
||||||
:role-options="roleOptions"
|
:role-options="roleOptions"
|
||||||
:loading="memberLoading"
|
:loading="memberLoading"
|
||||||
@@ -400,6 +462,7 @@ watch(
|
|||||||
@create="openCreateMember"
|
@create="openCreateMember"
|
||||||
@edit="openEditMember"
|
@edit="openEditMember"
|
||||||
@remove="openRemoveMember"
|
@remove="openRemoveMember"
|
||||||
|
@batch-remove="openBatchRemoveMember"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -427,11 +490,23 @@ watch(
|
|||||||
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
|
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
|
||||||
@submit="handleSubmitMemberOperate"
|
@submit="handleSubmitMemberOperate"
|
||||||
/>
|
/>
|
||||||
|
<ProductTeamBatchDialog
|
||||||
|
v-model:visible="memberBatchVisible"
|
||||||
|
:user-options="userOptions"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
|
||||||
|
@submit="handleSubmitMemberBatch"
|
||||||
|
/>
|
||||||
<MemberRemoveDialog
|
<MemberRemoveDialog
|
||||||
v-model:visible="memberRemoveVisible"
|
v-model:visible="memberRemoveVisible"
|
||||||
:member="selectedMember"
|
:member="selectedMember"
|
||||||
@submit="handleSubmitRemoveMember"
|
@submit="handleSubmitRemoveMember"
|
||||||
/>
|
/>
|
||||||
|
<MemberBatchRemoveDialog
|
||||||
|
v-model:visible="memberBatchRemoveVisible"
|
||||||
|
:members="selectedBatchRemoveMembers"
|
||||||
|
@submit="handleSubmitBatchRemoveMember"
|
||||||
|
/>
|
||||||
<StatusActionDialog
|
<StatusActionDialog
|
||||||
v-model:visible="statusActionVisible"
|
v-model:visible="statusActionVisible"
|
||||||
:action="selectedAction"
|
:action="selectedAction"
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'MemberBatchRemoveDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
members: Api.Product.ProductMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: { reason: string | null }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewNames = computed(() => {
|
||||||
|
const names = props.members.map(item => item.userNickname || item.userId || '').filter(Boolean);
|
||||||
|
|
||||||
|
if (names.length <= 5) {
|
||||||
|
return names.join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${names.slice(0, 5).join('、')} 等 ${names.length} 人`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('submit', {
|
||||||
|
reason: model.reason.trim() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.reason = '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" title="批量移出成员" preset="sm" @confirm="handleConfirm">
|
||||||
|
<ElAlert
|
||||||
|
:title="`确认将选中的 ${props.members.length} 名成员(${previewNames})从当前产品团队中移出吗?`"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-16px"
|
||||||
|
/>
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<ElFormItem label="移出原因">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入移出原因(统一应用到所有选中成员)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import type { TableInstance } from 'element-plus';
|
||||||
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
|
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
|
||||||
|
|
||||||
defineOptions({ name: 'SettingTeamPanel' });
|
defineOptions({ name: 'SettingTeamPanel' });
|
||||||
@@ -15,6 +16,7 @@ interface Emits {
|
|||||||
(e: 'create'): void;
|
(e: 'create'): void;
|
||||||
(e: 'edit', member: Api.Product.ProductMember): void;
|
(e: 'edit', member: Api.Product.ProductMember): void;
|
||||||
(e: 'remove', member: Api.Product.ProductMember): void;
|
(e: 'remove', member: Api.Product.ProductMember): void;
|
||||||
|
(e: 'batch-remove', members: Api.Product.ProductMember[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -26,19 +28,41 @@ const emit = defineEmits<Emits>();
|
|||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const selectedRoleId = ref('');
|
const selectedRoleId = ref('');
|
||||||
const teamTableHeight = getProductTeamTableHeight(5);
|
const teamTableHeight = getProductTeamTableHeight(5);
|
||||||
|
const tableRef = ref<TableInstance | null>(null);
|
||||||
|
const selectedRows = ref<Api.Product.ProductMember[]>([]);
|
||||||
|
const selectedCount = computed(() => selectedRows.value.length);
|
||||||
|
|
||||||
|
function isRowSelectable(row: Api.Product.ProductMember) {
|
||||||
|
return row.status === 0 && !row.managerFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: Api.Product.ProductMember[]) {
|
||||||
|
selectedRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchRemove() {
|
||||||
|
if (!selectedRows.value.length) return;
|
||||||
|
emit('batch-remove', [...selectedRows.value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
tableRef.value?.clearSelection();
|
||||||
|
selectedRows.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ clearSelection });
|
||||||
const roleFilterOptions = computed(() => {
|
const roleFilterOptions = computed(() => {
|
||||||
const roleMap = new Map<string, string>();
|
const seen = new Set<string>();
|
||||||
|
const result: Api.SystemManage.RoleSimple[] = [];
|
||||||
|
|
||||||
props.roleOptions.forEach(role => {
|
props.roleOptions.forEach(role => {
|
||||||
if (!roleMap.has(role.id)) {
|
if (role.visible === 0) return;
|
||||||
roleMap.set(role.id, role.name);
|
if (seen.has(role.id)) return;
|
||||||
}
|
seen.add(role.id);
|
||||||
|
result.push(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...roleMap.entries()].map(([value, label]) => ({
|
return result;
|
||||||
value,
|
|
||||||
label
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
const filteredMembers = computed(() =>
|
const filteredMembers = computed(() =>
|
||||||
filterProductMembers(props.members, {
|
filterProductMembers(props.members, {
|
||||||
@@ -49,7 +73,7 @@ const filteredMembers = computed(() =>
|
|||||||
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
||||||
|
|
||||||
watch(roleFilterOptions, options => {
|
watch(roleFilterOptions, options => {
|
||||||
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
|
if (selectedRoleId.value && !options.some(item => item.id === selectedRoleId.value)) {
|
||||||
selectedRoleId.value = '';
|
selectedRoleId.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -72,12 +96,14 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-team-panel__toolbar">
|
<div class="setting-team-panel__toolbar">
|
||||||
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
|
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
|
||||||
<ElOption
|
<ElOption v-for="role in roleFilterOptions" :key="role.id" :label="role.name" :value="role.id">
|
||||||
v-for="option in roleFilterOptions"
|
<div class="setting-team-panel__role-option">
|
||||||
:key="option.value"
|
<span class="setting-team-panel__role-option-name">{{ role.name }}</span>
|
||||||
:label="option.label"
|
<ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
|
||||||
:value="option.value"
|
<icon-ep:info-filled class="setting-team-panel__role-option-info" @click.stop />
|
||||||
/>
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
||||||
<ElButton
|
<ElButton
|
||||||
@@ -89,35 +115,42 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
|
|||||||
>
|
>
|
||||||
新增成员
|
新增成员
|
||||||
</ElButton>
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="!props.readonly"
|
||||||
|
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:disabled="selectedCount === 0"
|
||||||
|
@click="handleBatchRemove"
|
||||||
|
>
|
||||||
|
批量移出{{ selectedCount > 0 ? `(${selectedCount})` : '' }}
|
||||||
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ElTable
|
<ElTable
|
||||||
|
ref="tableRef"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
:data="filteredMembers"
|
:data="filteredMembers"
|
||||||
:height="teamTableHeight"
|
:height="teamTableHeight"
|
||||||
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
||||||
border
|
border
|
||||||
row-key="id"
|
row-key="id"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
>
|
>
|
||||||
|
<ElTableColumn
|
||||||
|
v-if="!props.readonly"
|
||||||
|
type="selection"
|
||||||
|
width="48"
|
||||||
|
align="center"
|
||||||
|
:selectable="(row: Api.Product.ProductMember) => isRowSelectable(row)"
|
||||||
|
/>
|
||||||
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||||
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
||||||
<ElTableColumn label="当前角色" min-width="180">
|
<ElTableColumn label="当前角色" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="setting-team-panel__role-cell">
|
{{ row.roleName || '--' }}
|
||||||
<span class="setting-team-panel__role-main">{{ row.roleName || '--' }}</span>
|
|
||||||
<ElTag
|
|
||||||
v-for="extra in row.additionalRoleNames"
|
|
||||||
:key="extra"
|
|
||||||
size="small"
|
|
||||||
type="info"
|
|
||||||
effect="plain"
|
|
||||||
class="setting-team-panel__role-extra"
|
|
||||||
>
|
|
||||||
{{ extra }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn label="成员状态" width="110" align="center">
|
<ElTableColumn label="成员状态" width="110" align="center">
|
||||||
@@ -196,15 +229,31 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-team-panel__role-cell {
|
.setting-team-panel__role-option {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-team-panel__role-extra {
|
.setting-team-panel__role-option-name {
|
||||||
font-weight: 400;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__role-option-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__role-option-info:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
|
|||||||
1157
src/views/product/shared/components/product-team-batch-dialog.vue
Normal file
1157
src/views/product/shared/components/product-team-batch-dialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, reactive, watch } from 'vue';
|
import { computed, nextTick, reactive, watch } from 'vue';
|
||||||
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
|
|
||||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ProjectCreateTeamMemberDialog' });
|
defineOptions({ name: 'ProjectCreateTeamMemberDialog' });
|
||||||
|
|
||||||
type OperateMode = 'create' | 'edit';
|
|
||||||
|
|
||||||
interface DraftMemberInput {
|
interface DraftMemberInput {
|
||||||
userId: string;
|
userId: string;
|
||||||
roleId: string;
|
roleId: string;
|
||||||
@@ -16,22 +12,16 @@ interface DraftMemberInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: OperateMode;
|
|
||||||
initial: DraftMemberInput | null;
|
initial: DraftMemberInput | null;
|
||||||
userOptions: Api.SystemManage.UserSimple[];
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
roleOptions: Api.SystemManage.RoleSimple[];
|
roleOptions: Api.SystemManage.RoleSimple[];
|
||||||
/** 已使用且不可选的 userId(编辑模式应当排除当前行自身) */
|
|
||||||
disabledUserIds?: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'submit', payload: DraftMemberInput): void;
|
(e: 'submit', payload: DraftMemberInput): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = defineProps<Props>();
|
||||||
disabledUserIds: () => []
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
const visible = defineModel<boolean>('visible', { default: false });
|
const visible = defineModel<boolean>('visible', { default: false });
|
||||||
@@ -39,31 +29,19 @@ const visible = defineModel<boolean>('visible', { default: false });
|
|||||||
const { formRef, validate } = useForm();
|
const { formRef, validate } = useForm();
|
||||||
const { createRequiredRule } = useFormRules();
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
const model = reactive<DraftMemberInput>({
|
const model = reactive<DraftMemberInput>({ userId: '', roleId: '', remark: '' });
|
||||||
userId: '',
|
|
||||||
roleId: '',
|
|
||||||
remark: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
|
|
||||||
|
|
||||||
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
||||||
|
|
||||||
const rules = computed(
|
const rules = computed(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
|
|
||||||
roleId: [createRequiredRule('请选择角色')]
|
roleId: [createRequiredRule('请选择角色')]
|
||||||
}) satisfies Record<string, App.Global.FormRule[]>
|
}) satisfies Record<string, App.Global.FormRule[]>
|
||||||
);
|
);
|
||||||
|
|
||||||
function isManagerRole(role: Api.SystemManage.RoleSimple) {
|
|
||||||
return role.code === PROJECT_MANAGER_ROLE_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
await validate();
|
await validate();
|
||||||
|
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
userId: model.userId,
|
userId: model.userId,
|
||||||
roleId: model.roleId,
|
roleId: model.roleId,
|
||||||
@@ -72,34 +50,21 @@ async function handleConfirm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(visible, async value => {
|
watch(visible, async value => {
|
||||||
if (!value) {
|
if (!value) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
model.userId = props.initial?.userId || '';
|
model.userId = props.initial?.userId || '';
|
||||||
model.roleId = props.initial?.roleId || '';
|
model.roleId = props.initial?.roleId || '';
|
||||||
model.remark = props.initial?.remark || '';
|
model.remark = props.initial?.remark || '';
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm">
|
<BusinessFormDialog v-model="visible" title="调整成员角色" preset="sm" @confirm="handleConfirm">
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
<ElFormItem label="成员用户">
|
||||||
<BusinessUserSelect
|
|
||||||
v-model="model.userId"
|
|
||||||
:options="userOptions"
|
|
||||||
:disabled-user-ids="disabledUserIds"
|
|
||||||
disabled-label="已添加"
|
|
||||||
placeholder="请选择成员用户"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem v-else label="成员用户">
|
|
||||||
<ElInput
|
<ElInput
|
||||||
:model-value="userLabelMap.get(String(model.userId)) || ''"
|
:model-value="userLabelMap.get(String(model.userId)) || ''"
|
||||||
readonly
|
readonly
|
||||||
@@ -111,18 +76,7 @@ watch(visible, async value => {
|
|||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="目标角色" prop="roleId">
|
<ElFormItem label="目标角色" prop="roleId">
|
||||||
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
||||||
<ElOption
|
<ElOption v-for="role in roleOptions" :key="role.id" :label="role.name" :value="role.id" />
|
||||||
v-for="role in roleOptions"
|
|
||||||
:key="role.id"
|
|
||||||
:label="role.name"
|
|
||||||
:value="role.id"
|
|
||||||
:disabled="isManagerRole(role)"
|
|
||||||
>
|
|
||||||
<span>{{ role.name }}</span>
|
|
||||||
<span v-if="isManagerRole(role)" class="project-create-team-member-dialog__role-hint">
|
|
||||||
(已由第 1 步指定)
|
|
||||||
</span>
|
|
||||||
</ElOption>
|
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
@@ -144,12 +98,6 @@ watch(visible, async value => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.project-create-team-member-dialog__role-hint {
|
|
||||||
margin-left: 8px;
|
|
||||||
color: rgb(148 163 184 / 96%);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.project-create-team-member-dialog__readonly-input .el-input__wrapper) {
|
:deep(.project-create-team-member-dialog__readonly-input .el-input__wrapper) {
|
||||||
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
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;
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
|
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
|
||||||
import { fetchGetRoleSimpleList } from '@/service/api';
|
import ProjectTeamBatchDialog, {
|
||||||
import { getProjectTeamTableHeight } from '@/views/project/project/setting/shared';
|
type BatchMemberPayload
|
||||||
|
} from '@/views/project/shared/components/project-team-batch-dialog.vue';
|
||||||
import ProjectCreateTeamMemberDialog from './project-create-team-member-dialog.vue';
|
import ProjectCreateTeamMemberDialog from './project-create-team-member-dialog.vue';
|
||||||
import type { ProjectCreateBaseForm } from './project-create-base-form.vue';
|
import type { ProjectCreateBaseForm } from './project-create-base-form.vue';
|
||||||
|
|
||||||
@@ -21,57 +22,34 @@ interface DraftMember {
|
|||||||
interface Props {
|
interface Props {
|
||||||
baseInfo: ProjectCreateBaseForm;
|
baseInfo: ProjectCreateBaseForm;
|
||||||
userOptions: Api.SystemManage.UserSimple[];
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
roleOptions: Api.SystemManage.RoleSimple[];
|
||||||
|
roleLoading: boolean;
|
||||||
|
managerRoleError: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:members', members: Api.Project.CreateProjectMemberParams[]): void;
|
(e: 'update:members', members: Api.Project.CreateProjectMemberParams[]): void;
|
||||||
(e: 'update:watcherUserIds', watcherUserIds: string[]): void;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
|
||||||
const roleLoading = ref(false);
|
|
||||||
const managerRoleError = ref('');
|
|
||||||
const members = ref<DraftMember[]>([]);
|
const members = ref<DraftMember[]>([]);
|
||||||
|
|
||||||
const memberDialogVisible = ref(false);
|
const memberDialogVisible = ref(false);
|
||||||
const memberDialogMode = ref<'create' | 'edit'>('create');
|
|
||||||
const editingKey = ref<string | null>(null);
|
const editingKey = ref<string | null>(null);
|
||||||
|
|
||||||
const watcherUserIds = ref<string[]>([]);
|
const batchDialogVisible = ref(false);
|
||||||
|
|
||||||
// 关心人候选用户:排除已在团队成员列表中的用户(包含项目负责人本人)
|
const batchDisabledUserIds = computed(() => members.value.map(item => item.userId).filter(Boolean));
|
||||||
const watcherUserOptions = computed(() => {
|
|
||||||
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
|
|
||||||
return props.userOptions.filter(user => !memberUserIds.has(user.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamTableHeight = getProjectTeamTableHeight(4);
|
|
||||||
|
|
||||||
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
|
||||||
|
|
||||||
const managerRole = computed(() => roleOptions.value.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
|
const managerRole = computed(() => props.roleOptions.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
|
||||||
|
|
||||||
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
|
|
||||||
const dialogDisabledUserIds = computed(() => {
|
|
||||||
return members.value
|
|
||||||
.filter(item => !editingKey.value || item.key !== editingKey.value)
|
|
||||||
.map(item => item.userId)
|
|
||||||
.filter(Boolean);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialogInitial = computed(() => {
|
const dialogInitial = computed(() => {
|
||||||
if (memberDialogMode.value === 'create' || !editingKey.value) {
|
if (!editingKey.value) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = members.value.find(item => item.key === editingKey.value);
|
const target = members.value.find(item => item.key === editingKey.value);
|
||||||
|
if (!target) return null;
|
||||||
if (!target) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
|
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,31 +58,13 @@ function getUserNickname(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRoleName(roleId: string) {
|
function getRoleName(roleId: string) {
|
||||||
return roleOptions.value.find(item => item.id === roleId)?.name || '--';
|
return props.roleOptions.find(item => item.id === roleId)?.name || '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateKey() {
|
function generateKey() {
|
||||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoles() {
|
|
||||||
roleLoading.value = true;
|
|
||||||
managerRoleError.value = '';
|
|
||||||
|
|
||||||
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'project' });
|
|
||||||
|
|
||||||
roleLoading.value = false;
|
|
||||||
|
|
||||||
roleOptions.value = data ?? [];
|
|
||||||
|
|
||||||
if (!managerRole.value) {
|
|
||||||
managerRoleError.value = '未找到项目经理角色,请联系管理员';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshManagerRow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshManagerRow() {
|
function refreshManagerRow() {
|
||||||
const managerUserId = props.baseInfo.managerUserId;
|
const managerUserId = props.baseInfo.managerUserId;
|
||||||
|
|
||||||
@@ -132,14 +92,25 @@ function refreshManagerRow() {
|
|||||||
emitMembers();
|
emitMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openBatch() {
|
||||||
memberDialogMode.value = 'create';
|
batchDialogVisible.value = true;
|
||||||
editingKey.value = null;
|
}
|
||||||
memberDialogVisible.value = true;
|
|
||||||
|
function handleBatchSubmit(payloads: BatchMemberPayload[]) {
|
||||||
|
for (const p of payloads) {
|
||||||
|
members.value.push({
|
||||||
|
key: generateKey(),
|
||||||
|
userId: p.userId,
|
||||||
|
roleId: p.roleId,
|
||||||
|
remark: p.remark,
|
||||||
|
locked: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
batchDialogVisible.value = false;
|
||||||
|
emitMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: DraftMember) {
|
function openEdit(row: DraftMember) {
|
||||||
memberDialogMode.value = 'edit';
|
|
||||||
editingKey.value = row.key;
|
editingKey.value = row.key;
|
||||||
memberDialogVisible.value = true;
|
memberDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -149,27 +120,16 @@ function removeMember(key: string) {
|
|||||||
emitMembers();
|
emitMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
|
function handleMemberEditSubmit(payload: { userId: string; roleId: string; remark: string }) {
|
||||||
if (memberDialogMode.value === 'create') {
|
if (!editingKey.value) return;
|
||||||
members.value.push({
|
const idx = members.value.findIndex(item => item.key === editingKey.value);
|
||||||
key: generateKey(),
|
if (idx >= 0) {
|
||||||
userId: payload.userId,
|
members.value[idx] = {
|
||||||
|
...members.value[idx],
|
||||||
roleId: payload.roleId,
|
roleId: payload.roleId,
|
||||||
remark: payload.remark,
|
remark: payload.remark
|
||||||
locked: false
|
};
|
||||||
});
|
|
||||||
} else if (editingKey.value) {
|
|
||||||
const idx = members.value.findIndex(item => item.key === editingKey.value);
|
|
||||||
|
|
||||||
if (idx >= 0) {
|
|
||||||
members.value[idx] = {
|
|
||||||
...members.value[idx],
|
|
||||||
roleId: payload.roleId,
|
|
||||||
remark: payload.remark
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
memberDialogVisible.value = false;
|
memberDialogVisible.value = false;
|
||||||
emitMembers();
|
emitMembers();
|
||||||
}
|
}
|
||||||
@@ -188,8 +148,8 @@ function emitMembers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runValidate(): Promise<boolean> {
|
async function runValidate(): Promise<boolean> {
|
||||||
if (managerRoleError.value) {
|
if (props.managerRoleError) {
|
||||||
window.$message?.error(managerRoleError.value);
|
window.$message?.error(props.managerRoleError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,43 +174,32 @@ async function runValidate(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWatcherChange(ids: string[]) {
|
|
||||||
watcherUserIds.value = ids;
|
|
||||||
emit('update:watcherUserIds', ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 团队成员变化时,剔除已被加入团队的关心人,避免重叠
|
|
||||||
watch(
|
|
||||||
() => members.value.map(item => item.userId).join(','),
|
|
||||||
() => {
|
|
||||||
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
|
|
||||||
const filtered = watcherUserIds.value.filter(id => !memberUserIds.has(id));
|
|
||||||
|
|
||||||
if (filtered.length !== watcherUserIds.value.length) {
|
|
||||||
handleWatcherChange(filtered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(loadRoles);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.baseInfo.managerUserId,
|
() => props.baseInfo.managerUserId,
|
||||||
() => {
|
() => {
|
||||||
if (!managerRoleError.value && managerRole.value) {
|
if (!props.managerRoleError && managerRole.value) {
|
||||||
refreshManagerRow();
|
refreshManagerRow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// roleOptions 异步加载到位后,补一次 locked 行刷新
|
||||||
|
watch(managerRole, () => {
|
||||||
|
if (!props.managerRoleError && managerRole.value && props.baseInfo.managerUserId) {
|
||||||
|
refreshManagerRow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
defineExpose({ validate: runValidate });
|
defineExpose({ validate: runValidate });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="roleLoading" class="team-step">
|
<div v-loading="roleLoading" class="team-step">
|
||||||
<div class="team-step__toolbar">
|
<button type="button" class="team-step__add" :disabled="Boolean(managerRoleError)" @click="openBatch">
|
||||||
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
|
<icon-ep:plus class="team-step__add-icon" />
|
||||||
</div>
|
<span>新增成员</span>
|
||||||
|
<span class="team-step__add-hint">从部门 / 管理链路 / 全公司 批量选人</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<ElAlert
|
<ElAlert
|
||||||
v-if="managerRoleError"
|
v-if="managerRoleError"
|
||||||
@@ -261,62 +210,49 @@ defineExpose({ validate: runValidate });
|
|||||||
class="team-step__alert"
|
class="team-step__alert"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角“新增成员”添加">
|
<div class="team-step__table-wrap">
|
||||||
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
<ElTable :data="members" height="100%" border row-key="key" empty-text="点击右上角“新增成员”添加">
|
||||||
<ElTableColumn label="成员姓名" min-width="120">
|
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="成员姓名" min-width="120">
|
||||||
{{ getUserNickname(row.userId) }}
|
<template #default="{ row }">
|
||||||
</template>
|
{{ getUserNickname(row.userId) }}
|
||||||
</ElTableColumn>
|
</template>
|
||||||
<ElTableColumn label="当前角色" min-width="140">
|
</ElTableColumn>
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="当前角色" min-width="140">
|
||||||
{{ getRoleName(row.roleId) }}
|
<template #default="{ row }">
|
||||||
</template>
|
{{ getRoleName(row.roleId) }}
|
||||||
</ElTableColumn>
|
</template>
|
||||||
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
|
</ElTableColumn>
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
|
||||||
{{ row.remark || '--' }}
|
<template #default="{ row }">
|
||||||
</template>
|
{{ row.remark || '--' }}
|
||||||
</ElTableColumn>
|
</template>
|
||||||
<ElTableColumn label="操作" width="150" fixed="right" align="center">
|
</ElTableColumn>
|
||||||
<template #default="{ row }">
|
<ElTableColumn label="操作" width="150" fixed="right" align="center">
|
||||||
<div class="team-step__actions">
|
<template #default="{ row }">
|
||||||
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
|
<div class="team-step__actions">
|
||||||
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
|
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
|
||||||
</div>
|
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
|
||||||
</template>
|
</div>
|
||||||
</ElTableColumn>
|
</template>
|
||||||
</ElTable>
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
<div class="watcher-row">
|
|
||||||
<span class="watcher-row__label">
|
|
||||||
关心人
|
|
||||||
<span class="watcher-row__optional">(选填)</span>
|
|
||||||
</span>
|
|
||||||
<ElSelect
|
|
||||||
:model-value="watcherUserIds"
|
|
||||||
multiple
|
|
||||||
filterable
|
|
||||||
clearable
|
|
||||||
collapse-tags
|
|
||||||
collapse-tags-tooltip
|
|
||||||
:max-collapse-tags="3"
|
|
||||||
placeholder="可在列表 / 概览看到此项目的关注人"
|
|
||||||
class="watcher-row__select"
|
|
||||||
@update:model-value="handleWatcherChange"
|
|
||||||
>
|
|
||||||
<ElOption v-for="item in watcherUserOptions" :key="item.id" :value="item.id" :label="item.nickname" />
|
|
||||||
</ElSelect>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProjectCreateTeamMemberDialog
|
<ProjectCreateTeamMemberDialog
|
||||||
v-model:visible="memberDialogVisible"
|
v-model:visible="memberDialogVisible"
|
||||||
:mode="memberDialogMode"
|
|
||||||
:initial="dialogInitial"
|
:initial="dialogInitial"
|
||||||
:user-options="userOptions"
|
:user-options="userOptions"
|
||||||
:role-options="roleOptions"
|
:role-options="roleOptions"
|
||||||
:disabled-user-ids="dialogDisabledUserIds"
|
@submit="handleMemberEditSubmit"
|
||||||
@submit="handleMemberSubmit"
|
/>
|
||||||
|
|
||||||
|
<ProjectTeamBatchDialog
|
||||||
|
v-model:visible="batchDialogVisible"
|
||||||
|
:user-options="userOptions"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:disabled-user-ids="batchDisabledUserIds"
|
||||||
|
@submit="handleBatchSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -326,16 +262,65 @@ defineExpose({ validate: runValidate });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-step__toolbar {
|
.team-step__add {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px dashed var(--el-border-color-darker);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add:hover:not(:disabled) {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add-hint {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__add:hover:not(:disabled) .team-step__add-hint {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-step__alert {
|
.team-step__alert {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-step__table-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-step__actions {
|
.team-step__actions {
|
||||||
@@ -343,27 +328,4 @@ defineExpose({ validate: runValidate });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watcher-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watcher-row__label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgb(60 70 95 / 96%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.watcher-row__optional {
|
|
||||||
color: rgb(140 150 170 / 96%);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watcher-row__select {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElRow } from 'element-plus';
|
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElRow } from 'element-plus';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
|
||||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
import { fetchCreateProjectWithTeam, fetchGetProductPage, fetchUpdateProject } from '@/service/api';
|
import {
|
||||||
|
fetchCreateProjectWithTeam,
|
||||||
|
fetchGetProductPage,
|
||||||
|
fetchGetRoleSimpleList,
|
||||||
|
fetchUpdateProject
|
||||||
|
} from '@/service/api';
|
||||||
import { useDict } from '@/hooks/business/dict';
|
import { useDict } from '@/hooks/business/dict';
|
||||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
@@ -265,9 +271,29 @@ const baseFormRef = ref<InstanceType<typeof ProjectCreateBaseForm> | null>(null)
|
|||||||
const teamStepRef = ref<InstanceType<typeof ProjectCreateTeamStep> | null>(null);
|
const teamStepRef = ref<InstanceType<typeof ProjectCreateTeamStep> | null>(null);
|
||||||
const currentStep = ref<1 | 2>(1);
|
const currentStep = ref<1 | 2>(1);
|
||||||
|
|
||||||
|
// === 新增模式:角色列表(父级加载,下发给 team-step 与批量弹层) ===
|
||||||
|
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||||
|
const roleLoading = ref(false);
|
||||||
|
const managerRoleError = ref('');
|
||||||
|
|
||||||
|
const managerRole = computed(() => roleOptions.value.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
roleLoading.value = true;
|
||||||
|
managerRoleError.value = '';
|
||||||
|
|
||||||
|
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'project' });
|
||||||
|
|
||||||
|
roleLoading.value = false;
|
||||||
|
roleOptions.value = data ?? [];
|
||||||
|
|
||||||
|
if (!managerRole.value) {
|
||||||
|
managerRoleError.value = '未找到项目经理角色,请联系管理员';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createBaseModel = ref<ProjectCreateBaseFormModel>(createBaseInfo());
|
const createBaseModel = ref<ProjectCreateBaseFormModel>(createBaseInfo());
|
||||||
const draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]);
|
const draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]);
|
||||||
const draftWatcherUserIds = ref<string[]>([]);
|
|
||||||
|
|
||||||
function createBaseInfo(): ProjectCreateBaseFormModel {
|
function createBaseInfo(): ProjectCreateBaseFormModel {
|
||||||
return {
|
return {
|
||||||
@@ -325,8 +351,7 @@ async function handleCreateSubmit() {
|
|||||||
plannedEndDate: createBaseModel.value.plannedEndDate,
|
plannedEndDate: createBaseModel.value.plannedEndDate,
|
||||||
projectDesc: getNullableText(createBaseModel.value.projectDesc)
|
projectDesc: getNullableText(createBaseModel.value.projectDesc)
|
||||||
},
|
},
|
||||||
members: draftMembers.value,
|
members: draftMembers.value
|
||||||
watcherUserIds: draftWatcherUserIds.value.length > 0 ? draftWatcherUserIds.value : undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { error, data } = await fetchCreateProjectWithTeam(payload);
|
const { error, data } = await fetchCreateProjectWithTeam(payload);
|
||||||
@@ -355,8 +380,8 @@ watch(visible, async value => {
|
|||||||
editModel.value = createEditModel();
|
editModel.value = createEditModel();
|
||||||
createBaseModel.value = createBaseInfo();
|
createBaseModel.value = createBaseInfo();
|
||||||
draftMembers.value = [];
|
draftMembers.value = [];
|
||||||
draftWatcherUserIds.value = [];
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
await loadRoles();
|
||||||
editFormRef.value?.clearValidate();
|
editFormRef.value?.clearValidate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -521,7 +546,7 @@ watch(visible, async value => {
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
</BusinessFormDialog>
|
</BusinessFormDialog>
|
||||||
|
|
||||||
<!-- 新增模式:两步向导(复合内容特例,自定义 ElDialog 880px) -->
|
<!-- 新增模式:两步向导(复合内容特例,1080px,左侧概念区 + 右侧步骤面板) -->
|
||||||
<ElDialog
|
<ElDialog
|
||||||
v-else
|
v-else
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
@@ -530,37 +555,79 @@ watch(visible, async value => {
|
|||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
align-center
|
align-center
|
||||||
width="760px"
|
width="1080px"
|
||||||
>
|
>
|
||||||
<div class="project-create-dialog__stepbar">
|
<div class="project-create-dialog__split">
|
||||||
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }">
|
<aside class="project-create-dialog__guide">
|
||||||
<span class="project-create-dialog__step-index">1</span>
|
<div class="project-create-dialog__guide-hero">
|
||||||
<span class="project-create-dialog__step-text">
|
<div class="project-create-dialog__guide-hero-icon">
|
||||||
<strong>基础资料</strong>
|
<icon-ep:files />
|
||||||
<small>定义项目身份和负责人</small>
|
</div>
|
||||||
</span>
|
<div class="project-create-dialog__guide-hero-text">
|
||||||
</div>
|
<div class="project-create-dialog__guide-hero-title">项目</div>
|
||||||
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
|
<div class="project-create-dialog__guide-hero-sub">需求 · 任务 · 交付 的协同载体</div>
|
||||||
<span class="project-create-dialog__step-index">2</span>
|
</div>
|
||||||
<span class="project-create-dialog__step-text">
|
</div>
|
||||||
<strong>初始化团队</strong>
|
|
||||||
<small>配置对象域成员角色</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="project-create-dialog__body">
|
<p class="project-create-dialog__guide-lead">
|
||||||
<div v-show="currentStep === 1" class="project-create-dialog__panel">
|
项目帮助组织持续规划和交付业务价值,围绕计划周期、负责人和团队推进研发工作,串起需求、任务、缺陷和成果。
|
||||||
<ProjectCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" />
|
</p>
|
||||||
</div>
|
|
||||||
<div v-show="currentStep === 2" class="project-create-dialog__panel">
|
<section class="project-create-dialog__guide-section">
|
||||||
<ProjectCreateTeamStep
|
<h4>包含</h4>
|
||||||
ref="teamStepRef"
|
<p>需求、执行、任务、工作日志、缺陷、文档、统计报表。</p>
|
||||||
:base-info="createBaseModel"
|
</section>
|
||||||
:user-options="managerUserOptions"
|
|
||||||
@update:members="draftMembers = $event"
|
<section class="project-create-dialog__guide-section">
|
||||||
@update:watcher-user-ids="draftWatcherUserIds = $event"
|
<h4>参与人</h4>
|
||||||
/>
|
<p>项目经理(必填,创建后锁定) · 团队角色(开发 / 测试 / 业务 / 关注人)。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="project-create-dialog__guide-section">
|
||||||
|
<h4>命名建议</h4>
|
||||||
|
<p>建议使用业务团队约定俗成的简短名称,关联所属产品后会继承产品方向,影响下游归类。</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="project-create-dialog__main">
|
||||||
|
<div class="project-create-dialog__stepbar">
|
||||||
|
<div
|
||||||
|
class="project-create-dialog__step"
|
||||||
|
:class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }"
|
||||||
|
>
|
||||||
|
<span class="project-create-dialog__step-index">1</span>
|
||||||
|
<span class="project-create-dialog__step-text">
|
||||||
|
<strong>基础资料</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
|
||||||
|
<span class="project-create-dialog__step-index">2</span>
|
||||||
|
<span class="project-create-dialog__step-text">
|
||||||
|
<strong>初始化团队</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-create-dialog__body">
|
||||||
|
<div v-show="currentStep === 1" class="project-create-dialog__panel">
|
||||||
|
<ProjectCreateBaseForm
|
||||||
|
ref="baseFormRef"
|
||||||
|
v-model="createBaseModel"
|
||||||
|
:manager-user-options="managerUserOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="currentStep === 2" class="project-create-dialog__panel">
|
||||||
|
<ProjectCreateTeamStep
|
||||||
|
ref="teamStepRef"
|
||||||
|
:base-info="createBaseModel"
|
||||||
|
:user-options="managerUserOptions"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:role-loading="roleLoading"
|
||||||
|
:manager-role-error="managerRoleError"
|
||||||
|
@update:members="draftMembers = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -608,6 +675,92 @@ watch(visible, async value => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide {
|
||||||
|
padding: 28px 24px;
|
||||||
|
background: linear-gradient(180deg, #f7f9fc 0%, #fafbfc 100%);
|
||||||
|
border-right: 1px solid rgb(229 233 242 / 96%);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: min(720px, calc(100vh - 160px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid rgb(229 233 242 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-hero-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-hero-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-hero-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-hero-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-lead {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-section + .project-create-dialog__guide-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-section h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__guide-section p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-dialog__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.project-create-dialog__stepbar {
|
.project-create-dialog__stepbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -663,13 +816,15 @@ watch(visible, async value => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-create-dialog__body {
|
.project-create-dialog__body {
|
||||||
min-height: 0;
|
height: min(520px, calc(100vh - 240px));
|
||||||
max-height: min(560px, calc(100vh - 240px));
|
overflow: hidden;
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-create-dialog__panel {
|
.project-create-dialog__panel {
|
||||||
|
height: 100%;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-create-dialog__footer {
|
.project-create-dialog__footer {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useMediaQuery } from '@vueuse/core';
|
|||||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||||
import {
|
import {
|
||||||
|
fetchBatchCreateProjectMembers,
|
||||||
|
fetchBatchInactiveProjectMembers,
|
||||||
fetchChangeProjectStatus,
|
fetchChangeProjectStatus,
|
||||||
fetchCreateProjectMember,
|
fetchCreateProjectMember,
|
||||||
fetchDeleteProject,
|
fetchDeleteProject,
|
||||||
@@ -18,8 +20,12 @@ import {
|
|||||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
|
import ProjectTeamBatchDialog, {
|
||||||
|
type BatchMemberPayload
|
||||||
|
} from '@/views/project/shared/components/project-team-batch-dialog.vue';
|
||||||
import { useCurrentProject } from '../../shared/use-current-project';
|
import { useCurrentProject } from '../../shared/use-current-project';
|
||||||
import BaseInfoDialog from './modules/base-info-dialog.vue';
|
import BaseInfoDialog from './modules/base-info-dialog.vue';
|
||||||
|
import MemberBatchRemoveDialog from './modules/member-batch-remove-dialog.vue';
|
||||||
import MemberOperateDialog from './modules/member-operate-dialog.vue';
|
import MemberOperateDialog from './modules/member-operate-dialog.vue';
|
||||||
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
|
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
|
||||||
import ProjectDeleteDialog from './modules/project-delete-dialog.vue';
|
import ProjectDeleteDialog from './modules/project-delete-dialog.vue';
|
||||||
@@ -68,7 +74,11 @@ const pageLoading = ref(false);
|
|||||||
const memberLoading = ref(false);
|
const memberLoading = ref(false);
|
||||||
const baseInfoVisible = ref(false);
|
const baseInfoVisible = ref(false);
|
||||||
const memberOperateVisible = ref(false);
|
const memberOperateVisible = ref(false);
|
||||||
|
const memberBatchVisible = ref(false);
|
||||||
const memberRemoveVisible = ref(false);
|
const memberRemoveVisible = ref(false);
|
||||||
|
const memberBatchRemoveVisible = ref(false);
|
||||||
|
const teamPanelRef = ref<InstanceType<typeof SettingTeamPanel> | null>(null);
|
||||||
|
const selectedBatchRemoveMembers = ref<Api.Project.ProjectMember[]>([]);
|
||||||
const statusActionVisible = ref(false);
|
const statusActionVisible = ref(false);
|
||||||
const deleteVisible = ref(false);
|
const deleteVisible = ref(false);
|
||||||
const memberOperateMode = ref<'create' | 'edit'>('create');
|
const memberOperateMode = ref<'create' | 'edit'>('create');
|
||||||
@@ -211,9 +221,7 @@ function scrollToSection(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateMember() {
|
function openCreateMember() {
|
||||||
memberOperateMode.value = 'create';
|
memberBatchVisible.value = true;
|
||||||
selectedMember.value = null;
|
|
||||||
memberOperateVisible.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditMember(member: Api.Project.ProjectMember) {
|
function openEditMember(member: Api.Project.ProjectMember) {
|
||||||
@@ -227,6 +235,12 @@ function openRemoveMember(member: Api.Project.ProjectMember) {
|
|||||||
memberRemoveVisible.value = true;
|
memberRemoveVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBatchRemoveMember(targetMembers: Api.Project.ProjectMember[]) {
|
||||||
|
if (!targetMembers.length) return;
|
||||||
|
selectedBatchRemoveMembers.value = targetMembers;
|
||||||
|
memberBatchRemoveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function openLifecycleAction(action: Api.Project.ProjectLifecycleAction) {
|
function openLifecycleAction(action: Api.Project.ProjectLifecycleAction) {
|
||||||
selectedAction.value = action;
|
selectedAction.value = action;
|
||||||
statusActionVisible.value = true;
|
statusActionVisible.value = true;
|
||||||
@@ -299,6 +313,53 @@ async function handleSubmitRemoveMember(payload: Api.Project.InactiveProjectMemb
|
|||||||
await Promise.all([loadMembers(), loadSettings()]);
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSubmitMemberBatch(payloads: BatchMemberPayload[]) {
|
||||||
|
if (!currentObjectId.value || !payloads.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await fetchBatchCreateProjectMembers(currentObjectId.value, {
|
||||||
|
members: payloads.map(item => ({
|
||||||
|
userId: item.userId,
|
||||||
|
roleId: item.roleId,
|
||||||
|
remark: item.remark.trim() || null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success(`已新增 ${payloads.length} 名成员`);
|
||||||
|
memberBatchVisible.value = false;
|
||||||
|
|
||||||
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitBatchRemoveMember(payload: { reason: string | null }) {
|
||||||
|
if (!currentObjectId.value || !selectedBatchRemoveMembers.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = selectedBatchRemoveMembers.value.map(item => item.id).filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
if (!memberIds.length) return;
|
||||||
|
|
||||||
|
const { error } = await fetchBatchInactiveProjectMembers(currentObjectId.value, {
|
||||||
|
memberIds,
|
||||||
|
reason: payload.reason
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
window.$message?.success(`已移出 ${memberIds.length} 名成员`);
|
||||||
|
memberBatchRemoveVisible.value = false;
|
||||||
|
selectedBatchRemoveMembers.value = [];
|
||||||
|
teamPanelRef.value?.clearSelection();
|
||||||
|
|
||||||
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitLifecycleAction(payload: Api.Project.ChangeProjectStatusParams) {
|
async function handleSubmitLifecycleAction(payload: Api.Project.ChangeProjectStatusParams) {
|
||||||
if (!currentObjectId.value || !selectedAction.value) {
|
if (!currentObjectId.value || !selectedAction.value) {
|
||||||
return;
|
return;
|
||||||
@@ -387,6 +448,7 @@ watch(
|
|||||||
|
|
||||||
<section :id="sectionIdMap.team" class="project-setting-page__section">
|
<section :id="sectionIdMap.team" class="project-setting-page__section">
|
||||||
<SettingTeamPanel
|
<SettingTeamPanel
|
||||||
|
ref="teamPanelRef"
|
||||||
:members="members"
|
:members="members"
|
||||||
:role-options="roleOptions"
|
:role-options="roleOptions"
|
||||||
:loading="memberLoading"
|
:loading="memberLoading"
|
||||||
@@ -394,6 +456,7 @@ watch(
|
|||||||
@create="openCreateMember"
|
@create="openCreateMember"
|
||||||
@edit="openEditMember"
|
@edit="openEditMember"
|
||||||
@remove="openRemoveMember"
|
@remove="openRemoveMember"
|
||||||
|
@batch-remove="openBatchRemoveMember"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -432,6 +495,18 @@ watch(
|
|||||||
:member="selectedMember"
|
:member="selectedMember"
|
||||||
@submit="handleSubmitRemoveMember"
|
@submit="handleSubmitRemoveMember"
|
||||||
/>
|
/>
|
||||||
|
<ProjectTeamBatchDialog
|
||||||
|
v-model:visible="memberBatchVisible"
|
||||||
|
:user-options="userOptions"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
|
||||||
|
@submit="handleSubmitMemberBatch"
|
||||||
|
/>
|
||||||
|
<MemberBatchRemoveDialog
|
||||||
|
v-model:visible="memberBatchRemoveVisible"
|
||||||
|
:members="selectedBatchRemoveMembers"
|
||||||
|
@submit="handleSubmitBatchRemoveMember"
|
||||||
|
/>
|
||||||
<StatusActionDialog
|
<StatusActionDialog
|
||||||
v-model:visible="statusActionVisible"
|
v-model:visible="statusActionVisible"
|
||||||
:action="selectedAction"
|
:action="selectedAction"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectMemberBatchRemoveDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
members: Api.Project.ProjectMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: { reason: string | null }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewNames = computed(() => {
|
||||||
|
const names = props.members.map(item => item.userNickname || item.userId || '').filter(Boolean);
|
||||||
|
|
||||||
|
if (names.length <= 5) {
|
||||||
|
return names.join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${names.slice(0, 5).join('、')} 等 ${names.length} 人`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('submit', {
|
||||||
|
reason: model.reason.trim() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.reason = '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" title="批量移出成员" preset="sm" @confirm="handleConfirm">
|
||||||
|
<ElAlert
|
||||||
|
:title="`确认将选中的 ${props.members.length} 名成员(${previewNames})从当前项目团队中移出吗?`"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-12px"
|
||||||
|
/>
|
||||||
|
<ElAlert
|
||||||
|
title="若任一成员仍担任未关闭执行的负责人,后端会整体拒绝;请先完成执行负责人交接后再批量移出。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-16px"
|
||||||
|
/>
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<ElFormItem label="移出原因">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入移出原因(统一应用到所有选中成员)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import type { TableInstance } from 'element-plus';
|
||||||
import { filterProjectMembers, formatProjectMemberDate, getProjectTeamTableHeight } from '../shared';
|
import { filterProjectMembers, formatProjectMemberDate, getProjectTeamTableHeight } from '../shared';
|
||||||
|
|
||||||
defineOptions({ name: 'ProjectSettingTeamPanel' });
|
defineOptions({ name: 'ProjectSettingTeamPanel' });
|
||||||
@@ -15,6 +16,7 @@ interface Emits {
|
|||||||
(e: 'create'): void;
|
(e: 'create'): void;
|
||||||
(e: 'edit', member: Api.Project.ProjectMember): void;
|
(e: 'edit', member: Api.Project.ProjectMember): void;
|
||||||
(e: 'remove', member: Api.Project.ProjectMember): void;
|
(e: 'remove', member: Api.Project.ProjectMember): void;
|
||||||
|
(e: 'batch-remove', members: Api.Project.ProjectMember[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -26,19 +28,41 @@ const emit = defineEmits<Emits>();
|
|||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const selectedRoleId = ref('');
|
const selectedRoleId = ref('');
|
||||||
const teamTableHeight = getProjectTeamTableHeight(5);
|
const teamTableHeight = getProjectTeamTableHeight(5);
|
||||||
|
const tableRef = ref<TableInstance | null>(null);
|
||||||
|
const selectedRows = ref<Api.Project.ProjectMember[]>([]);
|
||||||
|
const selectedCount = computed(() => selectedRows.value.length);
|
||||||
|
|
||||||
|
function isRowSelectable(row: Api.Project.ProjectMember) {
|
||||||
|
return row.status === 0 && !row.managerFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: Api.Project.ProjectMember[]) {
|
||||||
|
selectedRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchRemove() {
|
||||||
|
if (!selectedRows.value.length) return;
|
||||||
|
emit('batch-remove', [...selectedRows.value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
tableRef.value?.clearSelection();
|
||||||
|
selectedRows.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ clearSelection });
|
||||||
const roleFilterOptions = computed(() => {
|
const roleFilterOptions = computed(() => {
|
||||||
const roleMap = new Map<string, string>();
|
const seen = new Set<string>();
|
||||||
|
const result: Api.SystemManage.RoleSimple[] = [];
|
||||||
|
|
||||||
props.roleOptions.forEach(role => {
|
props.roleOptions.forEach(role => {
|
||||||
if (!roleMap.has(role.id)) {
|
if (role.visible === 0) return;
|
||||||
roleMap.set(role.id, role.name);
|
if (seen.has(role.id)) return;
|
||||||
}
|
seen.add(role.id);
|
||||||
|
result.push(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...roleMap.entries()].map(([value, label]) => ({
|
return result;
|
||||||
value,
|
|
||||||
label
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
const filteredMembers = computed(() =>
|
const filteredMembers = computed(() =>
|
||||||
filterProjectMembers(props.members, {
|
filterProjectMembers(props.members, {
|
||||||
@@ -49,7 +73,7 @@ const filteredMembers = computed(() =>
|
|||||||
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
||||||
|
|
||||||
watch(roleFilterOptions, options => {
|
watch(roleFilterOptions, options => {
|
||||||
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
|
if (selectedRoleId.value && !options.some(item => item.id === selectedRoleId.value)) {
|
||||||
selectedRoleId.value = '';
|
selectedRoleId.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -72,44 +96,46 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-team-panel__toolbar">
|
<div class="setting-team-panel__toolbar">
|
||||||
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
|
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
|
||||||
<ElOption
|
<ElOption v-for="role in roleFilterOptions" :key="role.id" :label="role.name" :value="role.id">
|
||||||
v-for="option in roleFilterOptions"
|
<div class="setting-team-panel__role-option">
|
||||||
:key="option.value"
|
<span class="setting-team-panel__role-option-name">{{ role.name }}</span>
|
||||||
:label="option.label"
|
<ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
|
||||||
:value="option.value"
|
<icon-ep:info-filled class="setting-team-panel__role-option-info" @click.stop />
|
||||||
/>
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
||||||
<ElButton type="primary" plain :disabled="props.readonly" @click="emit('create')">新增成员</ElButton>
|
<ElButton type="primary" plain :disabled="props.readonly" @click="emit('create')">新增成员</ElButton>
|
||||||
|
<ElButton type="danger" plain :disabled="props.readonly || selectedCount === 0" @click="handleBatchRemove">
|
||||||
|
批量移出{{ selectedCount > 0 ? `(${selectedCount})` : '' }}
|
||||||
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ElTable
|
<ElTable
|
||||||
|
ref="tableRef"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
:data="filteredMembers"
|
:data="filteredMembers"
|
||||||
:height="teamTableHeight"
|
:height="teamTableHeight"
|
||||||
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
||||||
border
|
border
|
||||||
row-key="id"
|
row-key="id"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
>
|
>
|
||||||
|
<ElTableColumn
|
||||||
|
v-if="!props.readonly"
|
||||||
|
type="selection"
|
||||||
|
width="48"
|
||||||
|
align="center"
|
||||||
|
:selectable="(row: Api.Project.ProjectMember) => isRowSelectable(row)"
|
||||||
|
/>
|
||||||
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||||
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
||||||
<ElTableColumn label="当前角色" min-width="180">
|
<ElTableColumn label="当前角色" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="setting-team-panel__role-cell">
|
{{ row.roleName || '--' }}
|
||||||
<span class="setting-team-panel__role-main">{{ row.roleName || '--' }}</span>
|
|
||||||
<ElTag
|
|
||||||
v-for="extra in row.additionalRoleNames"
|
|
||||||
:key="extra"
|
|
||||||
size="small"
|
|
||||||
type="info"
|
|
||||||
effect="plain"
|
|
||||||
class="setting-team-panel__role-extra"
|
|
||||||
>
|
|
||||||
{{ extra }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn label="成员状态" width="110" align="center">
|
<ElTableColumn label="成员状态" width="110" align="center">
|
||||||
@@ -186,15 +212,31 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-team-panel__role-cell {
|
.setting-team-panel__role-option {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-team-panel__role-extra {
|
.setting-team-panel__role-option-name {
|
||||||
font-weight: 400;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__role-option-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__role-option-info:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
|
|||||||
1157
src/views/project/shared/components/project-team-batch-dialog.vue
Normal file
1157
src/views/project/shared/components/project-team-batch-dialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user