refactor(projects): 1、优化新增 产品和新增项目;2、调整角色提示信息

This commit is contained in:
2026-05-18 22:25:04 +08:00
parent 2367e03146
commit acd41555f9
22 changed files with 3588 additions and 643 deletions

View File

@@ -33,8 +33,6 @@ interface ProductMemberResponse {
roleId: string | number;
roleName: string;
roleCode: string;
/** 多角色合并展示的非主角色名列表 */
additionalRoleNames?: string[] | null;
managerFlag: boolean;
status: 0 | 1;
joinedTime: string;
@@ -76,7 +74,6 @@ export function normalizeProductMember(response: ProductMemberResponse): Api.Pro
roleId: normalizeStringId(response.roleId),
roleName: response.roleName || '',
roleCode: response.roleCode || '',
additionalRoleNames: response.additionalRoleNames ?? [],
managerFlag: Boolean(response.managerFlag),
status: response.status,
joinedTime: response.joinedTime,

View File

@@ -574,6 +574,19 @@ export async function fetchCreateProductMember(id: string, data: Api.Product.Cre
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) {
return request<boolean>({
...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(
id: string,
memberId: string,

View File

@@ -147,8 +147,6 @@ export interface ProjectMemberResponse {
roleId: string | number;
roleName: string;
roleCode: string;
/** 多角色合并展示的非主角色名列表 */
additionalRoleNames?: string[] | null;
managerFlag: boolean;
status: 0 | 1;
joinedTime: string;
@@ -227,7 +225,6 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
roleId: normalizeStringId(response.roleId),
roleName: response.roleName || '',
roleCode: response.roleCode || '',
additionalRoleNames: response.additionalRoleNames ?? [],
managerFlag: Boolean(response.managerFlag),
status: response.status,
joinedTime: response.joinedTime,

View File

@@ -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) {
const result = await fetchGetProject(id);

View File

@@ -99,15 +99,10 @@ declare namespace Api {
userNickname: string;
/** 角色 ID */
roleId: string;
/** 角色名称(主角色) */
/** 角色名称 */
roleName: string;
/** 角色编码(主角色) */
/** 角色编码 */
roleCode: string;
/**
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
* 单角色时为空数组 [];典型场景:创建者 + 经理重合时,主行 managercreator 名进此列表
*/
additionalRoleNames: string[];
/** 是否当前产品经理 */
managerFlag: boolean;
/** 成员状态 */
@@ -215,6 +210,20 @@ declare namespace Api {
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 {
product: SaveProductParams;
members: CreateProductMemberParams[];
/** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
/** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
watcherUserIds?: string[];
}
@@ -239,6 +248,11 @@ declare namespace Api {
reason?: string | null;
}
interface BatchInactiveProductMembersParams {
memberIds: string[];
reason?: string | null;
}
// ========== 产品需求相关类型定义 ==========
/** 需求状态编码 */
type RequirementStatusCode =

View File

@@ -519,15 +519,10 @@ declare namespace Api {
userNickname: string;
/** 角色 ID */
roleId: string;
/** 角色名称(主角色) */
/** 角色名称 */
roleName: string;
/** 角色编码(主角色) */
/** 角色编码 */
roleCode: string;
/**
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
* 单角色时为空数组 [];典型场景:创建者 + 负责人重合时,主行 managercreator 名进此列表
*/
additionalRoleNames: string[];
/** 是否项目负责人 */
managerFlag: boolean;
/** 成员状态 */
@@ -625,6 +620,26 @@ declare namespace Api {
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 {
project: SaveProjectParams;
members: CreateProjectMemberParams[];
/** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */
/** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */
watcherUserIds?: string[];
}

View File

@@ -47,6 +47,8 @@ declare namespace Api {
type: RoleType;
/** remark */
remark?: string | null;
/** 是否在前端选择面板可见0 不可见 / 1 可见,缺省视作可见 */
visible?: 0 | 1 | null;
/** create time */
createTime: number;
}
@@ -226,7 +228,7 @@ declare namespace Api {
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[];

View File

@@ -100,6 +100,10 @@ declare module 'vue' {
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
'IconCharm:download': typeof import('~icons/charm/download')['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']
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']

View File

@@ -1,14 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProductCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput {
userId: string;
roleId: string;
@@ -16,22 +12,16 @@ interface DraftMemberInput {
}
interface Props {
mode: OperateMode;
initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
}
interface Emits {
(e: 'submit', payload: DraftMemberInput): void;
}
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
@@ -39,31 +29,21 @@ const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const model = reactive<DraftMemberInput>({ userId: '', roleId: '', remark: '' });
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(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PRODUCT_MANAGER_ROLE_CODE;
}
async function handleConfirm() {
await validate();
emit('submit', {
userId: model.userId,
roleId: model.roleId,
@@ -72,34 +52,21 @@ async function handleConfirm() {
}
watch(visible, async value => {
if (!value) {
return;
}
if (!value) return;
model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || '';
await nextTick();
formRef.value?.clearValidate();
});
</script>
<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">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElFormItem label="成员用户">
<ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''"
readonly
@@ -111,17 +78,13 @@ watch(visible, async value => {
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption
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="product-create-team-member-dialog__role-hint">
已由第 1 步指定
</span>
<ElOption v-for="role in selectableRoles" :key="role.id" :label="role.name" :value="role.id">
<div class="product-create-team-member-dialog__role-option">
<span class="product-create-team-member-dialog__role-option-name">{{ role.name }}</span>
<ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
<icon-ep:info-filled class="product-create-team-member-dialog__role-option-info" @click.stop />
</ElTooltip>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
@@ -144,12 +107,6 @@ watch(visible, async value => {
</template>
<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) {
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;
@@ -166,4 +123,31 @@ watch(visible, async value => {
-webkit-text-fill-color: rgb(51 65 85 / 96%);
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>

View File

@@ -1,8 +1,9 @@
<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 { fetchGetRoleSimpleList } from '@/service/api';
import { getProductTeamTableHeight } from '../../setting/shared';
import ProductTeamBatchDialog, {
type BatchMemberPayload
} from '@/views/product/shared/components/product-team-batch-dialog.vue';
import ProductCreateTeamMemberDialog from './product-create-team-member-dialog.vue';
import type { ProductCreateBaseForm } from './product-create-base-form.vue';
@@ -21,57 +22,34 @@ interface DraftMember {
interface Props {
baseInfo: ProductCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
roleLoading: boolean;
managerRoleError: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(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 memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const watcherUserIds = ref<string[]>([]);
const batchDialogVisible = ref(false);
// 关心人候选用户:排除已在团队成员列表中的用户(包含产品经理本人)
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 batchDisabledUserIds = computed(() => members.value.map(item => item.userId).filter(Boolean));
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 dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const managerRole = computed(() => props.roleOptions.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) {
return null;
}
if (!editingKey.value) return null;
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 };
});
@@ -80,31 +58,13 @@ function getUserNickname(userId: 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() {
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() {
const managerUserId = props.baseInfo.managerUserId;
@@ -132,14 +92,25 @@ function refreshManagerRow() {
emitMembers();
}
function openCreate() {
memberDialogMode.value = 'create';
editingKey.value = null;
memberDialogVisible.value = true;
function openBatch() {
batchDialogVisible.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) {
memberDialogMode.value = 'edit';
editingKey.value = row.key;
memberDialogVisible.value = true;
}
@@ -149,27 +120,16 @@ function removeMember(key: string) {
emitMembers();
}
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') {
members.value.push({
key: generateKey(),
userId: payload.userId,
function handleMemberEditSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (!editingKey.value) return;
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,
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
};
}
remark: payload.remark
};
}
memberDialogVisible.value = false;
emitMembers();
}
@@ -188,8 +148,8 @@ function emitMembers() {
}
async function runValidate(): Promise<boolean> {
if (managerRoleError.value) {
window.$message?.error(managerRoleError.value);
if (props.managerRoleError) {
window.$message?.error(props.managerRoleError);
return false;
}
@@ -214,43 +174,32 @@ async function runValidate(): Promise<boolean> {
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(
() => props.baseInfo.managerUserId,
() => {
if (!managerRoleError.value && managerRole.value) {
if (!props.managerRoleError && managerRole.value) {
refreshManagerRow();
}
}
);
// roleOptions 异步加载到位后,补一次 locked 行刷新
watch(managerRole, () => {
if (!props.managerRoleError && managerRole.value && props.baseInfo.managerUserId) {
refreshManagerRow();
}
});
defineExpose({ validate: runValidate });
</script>
<template>
<div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
</div>
<button type="button" class="team-step__add" :disabled="Boolean(managerRoleError)" @click="openBatch">
<icon-ep:plus class="team-step__add-icon" />
<span>新增成员</span>
<span class="team-step__add-hint">从部门 / 管理链路 / 全公司 批量选人</span>
</button>
<ElAlert
v-if="managerRoleError"
@@ -261,62 +210,49 @@ defineExpose({ validate: runValidate });
class="team-step__alert"
/>
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</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 class="team-step__table-wrap">
<ElTable :data="members" height="100%" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</div>
<ProductCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds"
@submit="handleMemberSubmit"
@submit="handleMemberEditSubmit"
/>
<ProductTeamBatchDialog
v-model:visible="batchDialogVisible"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="batchDisabledUserIds"
@submit="handleBatchSubmit"
/>
</div>
</template>
@@ -326,16 +262,65 @@ defineExpose({ validate: runValidate });
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
}
.team-step__toolbar {
.team-step__add {
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 {
margin: 0;
flex-shrink: 0;
}
.team-step__table-wrap {
flex: 1 1 auto;
min-height: 0;
}
.team-step__actions {
@@ -343,27 +328,4 @@ defineExpose({ validate: runValidate });
align-items: center;
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>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
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 { fetchCreateProductWithTeam, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { fetchCreateProductWithTeam, fetchGetProduct, fetchGetRoleSimpleList, fetchUpdateProduct } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.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 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 draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]);
const draftWatcherUserIds = ref<string[]>([]);
function createBaseInfo(): ProductCreateBaseFormModel {
return { code: '', name: '', directionCode: '', managerUserId: null, description: '' };
@@ -158,8 +179,7 @@ async function handleCreateSubmit() {
managerUserId: createBaseModel.value.managerUserId as string,
description: getNullableText(createBaseModel.value.description)
},
members: draftMembers.value,
watcherUserIds: draftWatcherUserIds.value.length > 0 ? draftWatcherUserIds.value : undefined
members: draftMembers.value
};
const { error, data } = await fetchCreateProductWithTeam(payload);
@@ -188,8 +208,8 @@ watch(visible, async value => {
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
draftWatcherUserIds.value = [];
await nextTick();
await loadRoles();
editFormRef.value?.clearValidate();
return;
}
@@ -295,7 +315,7 @@ watch(visible, async value => {
</ElForm>
</BusinessFormDialog>
<!-- 新增模式两步向导复合内容特例自定义 ElDialog 880px -->
<!-- 新增模式:两步向导(复合内容特例,1080px,左侧概念区 + 右侧步骤面板) -->
<ElDialog
v-else
v-model="visible"
@@ -304,43 +324,85 @@ watch(visible, async value => {
:close-on-click-modal="false"
destroy-on-close
align-center
width="760px"
width="1080px"
>
<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>
<small>定义产品身份和负责人</small>
</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>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="product-create-dialog__split">
<aside class="product-create-dialog__guide">
<div class="product-create-dialog__guide-hero">
<div class="product-create-dialog__guide-hero-icon">
<icon-ep:box />
</div>
<div class="product-create-dialog__guide-hero-text">
<div class="product-create-dialog__guide-hero-title">产品</div>
<div class="product-create-dialog__guide-hero-sub">需求 · 项目 · 迭代 的承载单元</div>
</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"
@update:members="draftMembers = $event"
@update:watcher-user-ids="draftWatcherUserIds = $event"
/>
<p class="product-create-dialog__guide-lead">
产品与需求池管理,提供多维度的需求规划工具,打通客户业务团队和产研团队之间的协作
</p>
<section class="product-create-dialog__guide-section">
<h4>包含</h4>
<p>需求变更迭代模块文档状态统计报表</p>
</section>
<section class="product-create-dialog__guide-section">
<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>
<template #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">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
@@ -378,6 +440,92 @@ watch(visible, async value => {
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -433,13 +581,15 @@ watch(visible, async value => {
}
.product-create-dialog__body {
min-height: 0;
max-height: min(560px, calc(100vh - 240px));
overflow: auto;
height: min(520px, calc(100vh - 240px));
overflow: hidden;
}
.product-create-dialog__panel {
height: 100%;
padding: 24px;
overflow: auto;
box-sizing: border-box;
}
.product-create-dialog__footer {

View File

@@ -4,6 +4,8 @@ import { useMediaQuery } from '@vueuse/core';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { objectContextDomainConfigs } from '@/constants/object-context';
import {
fetchBatchCreateProductMembers,
fetchBatchInactiveProductMembers,
fetchChangeProductStatus,
fetchCreateProductMember,
fetchDeleteProduct,
@@ -19,8 +21,12 @@ import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
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 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 MemberRemoveDialog from './modules/member-remove-dialog.vue';
import ProductDeleteDialog from './modules/product-delete-dialog.vue';
@@ -70,7 +76,11 @@ const pageLoading = ref(false);
const memberLoading = ref(false);
const baseInfoVisible = ref(false);
const memberOperateVisible = ref(false);
const memberBatchVisible = 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 deleteVisible = ref(false);
const memberOperateMode = ref<'create' | 'edit'>('create');
@@ -217,9 +227,7 @@ function scrollToSection(key: string) {
}
function openCreateMember() {
memberOperateMode.value = 'create';
selectedMember.value = null;
memberOperateVisible.value = true;
memberBatchVisible.value = true;
}
function openEditMember(member: Api.Product.ProductMember) {
@@ -233,6 +241,12 @@ function openRemoveMember(member: Api.Product.ProductMember) {
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) {
selectedAction.value = action;
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) {
if (!currentObjectId.value || !selectedMember.value?.id) {
return;
@@ -305,6 +342,30 @@ async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemb
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) {
if (!currentObjectId.value || !selectedAction.value) {
return;
@@ -393,6 +454,7 @@ watch(
<section :id="sectionIdMap.team" class="product-setting-page__section">
<SettingTeamPanel
ref="teamPanelRef"
:members="members"
:role-options="roleOptions"
:loading="memberLoading"
@@ -400,6 +462,7 @@ watch(
@create="openCreateMember"
@edit="openEditMember"
@remove="openRemoveMember"
@batch-remove="openBatchRemoveMember"
/>
</section>
@@ -427,11 +490,23 @@ watch(
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
@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
v-model:visible="memberRemoveVisible"
:member="selectedMember"
@submit="handleSubmitRemoveMember"
/>
<MemberBatchRemoveDialog
v-model:visible="memberBatchRemoveVisible"
:members="selectedBatchRemoveMembers"
@submit="handleSubmitBatchRemoveMember"
/>
<StatusActionDialog
v-model:visible="statusActionVisible"
:action="selectedAction"

View File

@@ -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>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus';
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
defineOptions({ name: 'SettingTeamPanel' });
@@ -15,6 +16,7 @@ interface Emits {
(e: 'create'): void;
(e: 'edit', 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>(), {
@@ -26,19 +28,41 @@ const emit = defineEmits<Emits>();
const searchKeyword = ref('');
const selectedRoleId = ref('');
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 roleMap = new Map<string, string>();
const seen = new Set<string>();
const result: Api.SystemManage.RoleSimple[] = [];
props.roleOptions.forEach(role => {
if (!roleMap.has(role.id)) {
roleMap.set(role.id, role.name);
}
if (role.visible === 0) return;
if (seen.has(role.id)) return;
seen.add(role.id);
result.push(role);
});
return [...roleMap.entries()].map(([value, label]) => ({
value,
label
}));
return result;
});
const filteredMembers = computed(() =>
filterProductMembers(props.members, {
@@ -49,7 +73,7 @@ const filteredMembers = computed(() =>
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
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 = '';
}
});
@@ -72,12 +96,14 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
</div>
<div class="setting-team-panel__toolbar">
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
<ElOption
v-for="option in roleFilterOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
<ElOption v-for="role in roleFilterOptions" :key="role.id" :label="role.name" :value="role.id">
<div class="setting-team-panel__role-option">
<span class="setting-team-panel__role-option-name">{{ role.name }}</span>
<ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
<icon-ep:info-filled class="setting-team-panel__role-option-info" @click.stop />
</ElTooltip>
</div>
</ElOption>
</ElSelect>
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
<ElButton
@@ -89,35 +115,42 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
>
新增成员
</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>
</template>
<ElTable
ref="tableRef"
v-loading="props.loading"
:data="filteredMembers"
:height="teamTableHeight"
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
border
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 prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn label="当前角色" min-width="180">
<template #default="{ row }">
<div class="setting-team-panel__role-cell">
<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>
{{ row.roleName || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="成员状态" width="110" align="center">
@@ -196,15 +229,31 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
gap: 12px;
}
.setting-team-panel__role-cell {
display: inline-flex;
.setting-team-panel__role-option {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.setting-team-panel__role-extra {
font-weight: 400;
.setting-team-panel__role-option-name {
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) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProjectCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput {
userId: string;
roleId: string;
@@ -16,22 +12,16 @@ interface DraftMemberInput {
}
interface Props {
mode: OperateMode;
initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
}
interface Emits {
(e: 'submit', payload: DraftMemberInput): void;
}
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
@@ -39,31 +29,19 @@ const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const model = reactive<DraftMemberInput>({ userId: '', roleId: '', remark: '' });
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PROJECT_MANAGER_ROLE_CODE;
}
async function handleConfirm() {
await validate();
emit('submit', {
userId: model.userId,
roleId: model.roleId,
@@ -72,34 +50,21 @@ async function handleConfirm() {
}
watch(visible, async value => {
if (!value) {
return;
}
if (!value) return;
model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || '';
await nextTick();
formRef.value?.clearValidate();
});
</script>
<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">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElFormItem label="成员用户">
<ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''"
readonly
@@ -111,18 +76,7 @@ watch(visible, async value => {
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption
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>
<ElOption v-for="role in roleOptions" :key="role.id" :label="role.name" :value="role.id" />
</ElSelect>
</ElFormItem>
</ElCol>
@@ -144,12 +98,6 @@ watch(visible, async value => {
</template>
<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) {
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;

View File

@@ -1,8 +1,9 @@
<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 { fetchGetRoleSimpleList } from '@/service/api';
import { getProjectTeamTableHeight } from '@/views/project/project/setting/shared';
import ProjectTeamBatchDialog, {
type BatchMemberPayload
} from '@/views/project/shared/components/project-team-batch-dialog.vue';
import ProjectCreateTeamMemberDialog from './project-create-team-member-dialog.vue';
import type { ProjectCreateBaseForm } from './project-create-base-form.vue';
@@ -21,57 +22,34 @@ interface DraftMember {
interface Props {
baseInfo: ProjectCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
roleLoading: boolean;
managerRoleError: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(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 memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const watcherUserIds = ref<string[]>([]);
const batchDialogVisible = ref(false);
// 关心人候选用户:排除已在团队成员列表中的用户(包含项目负责人本人)
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 batchDisabledUserIds = computed(() => members.value.map(item => item.userId).filter(Boolean));
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 dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const managerRole = computed(() => props.roleOptions.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) {
return null;
}
if (!editingKey.value) return null;
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 };
});
@@ -80,31 +58,13 @@ function getUserNickname(userId: 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() {
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() {
const managerUserId = props.baseInfo.managerUserId;
@@ -132,14 +92,25 @@ function refreshManagerRow() {
emitMembers();
}
function openCreate() {
memberDialogMode.value = 'create';
editingKey.value = null;
memberDialogVisible.value = true;
function openBatch() {
batchDialogVisible.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) {
memberDialogMode.value = 'edit';
editingKey.value = row.key;
memberDialogVisible.value = true;
}
@@ -149,27 +120,16 @@ function removeMember(key: string) {
emitMembers();
}
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') {
members.value.push({
key: generateKey(),
userId: payload.userId,
function handleMemberEditSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (!editingKey.value) return;
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,
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
};
}
remark: payload.remark
};
}
memberDialogVisible.value = false;
emitMembers();
}
@@ -188,8 +148,8 @@ function emitMembers() {
}
async function runValidate(): Promise<boolean> {
if (managerRoleError.value) {
window.$message?.error(managerRoleError.value);
if (props.managerRoleError) {
window.$message?.error(props.managerRoleError);
return false;
}
@@ -214,43 +174,32 @@ async function runValidate(): Promise<boolean> {
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(
() => props.baseInfo.managerUserId,
() => {
if (!managerRoleError.value && managerRole.value) {
if (!props.managerRoleError && managerRole.value) {
refreshManagerRow();
}
}
);
// roleOptions 异步加载到位后,补一次 locked 行刷新
watch(managerRole, () => {
if (!props.managerRoleError && managerRole.value && props.baseInfo.managerUserId) {
refreshManagerRow();
}
});
defineExpose({ validate: runValidate });
</script>
<template>
<div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
</div>
<button type="button" class="team-step__add" :disabled="Boolean(managerRoleError)" @click="openBatch">
<icon-ep:plus class="team-step__add-icon" />
<span>新增成员</span>
<span class="team-step__add-hint">从部门 / 管理链路 / 全公司 批量选人</span>
</button>
<ElAlert
v-if="managerRoleError"
@@ -261,62 +210,49 @@ defineExpose({ validate: runValidate });
class="team-step__alert"
/>
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</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 class="team-step__table-wrap">
<ElTable :data="members" height="100%" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</div>
<ProjectCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds"
@submit="handleMemberSubmit"
@submit="handleMemberEditSubmit"
/>
<ProjectTeamBatchDialog
v-model:visible="batchDialogVisible"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="batchDisabledUserIds"
@submit="handleBatchSubmit"
/>
</div>
</template>
@@ -326,16 +262,65 @@ defineExpose({ validate: runValidate });
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
}
.team-step__toolbar {
.team-step__add {
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 {
margin: 0;
flex-shrink: 0;
}
.team-step__table-wrap {
flex: 1 1 auto;
min-height: 0;
}
.team-step__actions {
@@ -343,27 +328,4 @@ defineExpose({ validate: runValidate });
align-items: center;
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>

View File

@@ -2,8 +2,14 @@
import { computed, nextTick, ref, watch } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElRow } from 'element-plus';
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 { fetchCreateProjectWithTeam, fetchGetProductPage, fetchUpdateProject } from '@/service/api';
import {
fetchCreateProjectWithTeam,
fetchGetProductPage,
fetchGetRoleSimpleList,
fetchUpdateProject
} from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
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 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 draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]);
const draftWatcherUserIds = ref<string[]>([]);
function createBaseInfo(): ProjectCreateBaseFormModel {
return {
@@ -325,8 +351,7 @@ async function handleCreateSubmit() {
plannedEndDate: createBaseModel.value.plannedEndDate,
projectDesc: getNullableText(createBaseModel.value.projectDesc)
},
members: draftMembers.value,
watcherUserIds: draftWatcherUserIds.value.length > 0 ? draftWatcherUserIds.value : undefined
members: draftMembers.value
};
const { error, data } = await fetchCreateProjectWithTeam(payload);
@@ -355,8 +380,8 @@ watch(visible, async value => {
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
draftWatcherUserIds.value = [];
await nextTick();
await loadRoles();
editFormRef.value?.clearValidate();
return;
}
@@ -521,7 +546,7 @@ watch(visible, async value => {
</ElForm>
</BusinessFormDialog>
<!-- 新增模式两步向导复合内容特例,自定义 ElDialog 880px -->
<!-- 新增模式:两步向导(复合内容特例,1080px,左侧概念区 + 右侧步骤面板) -->
<ElDialog
v-else
v-model="visible"
@@ -530,37 +555,79 @@ watch(visible, async value => {
:close-on-click-modal="false"
destroy-on-close
align-center
width="760px"
width="1080px"
>
<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>
<small>定义项目身份和负责人</small>
</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>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="project-create-dialog__split">
<aside class="project-create-dialog__guide">
<div class="project-create-dialog__guide-hero">
<div class="project-create-dialog__guide-hero-icon">
<icon-ep:files />
</div>
<div class="project-create-dialog__guide-hero-text">
<div class="project-create-dialog__guide-hero-title">项目</div>
<div class="project-create-dialog__guide-hero-sub">需求 · 任务 · 交付 的协同载体</div>
</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"
@update:members="draftMembers = $event"
@update:watcher-user-ids="draftWatcherUserIds = $event"
/>
<p class="project-create-dialog__guide-lead">
项目帮助组织持续规划和交付业务价值,围绕计划周期、负责人和团队推进研发工作,串起需求、任务、缺陷和成果。
</p>
<section class="project-create-dialog__guide-section">
<h4>包含</h4>
<p>需求、执行、任务、工作日志、缺陷、文档、统计报表。</p>
</section>
<section class="project-create-dialog__guide-section">
<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>
@@ -608,6 +675,92 @@ watch(visible, async value => {
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -663,13 +816,15 @@ watch(visible, async value => {
}
.project-create-dialog__body {
min-height: 0;
max-height: min(560px, calc(100vh - 240px));
overflow: auto;
height: min(520px, calc(100vh - 240px));
overflow: hidden;
}
.project-create-dialog__panel {
height: 100%;
padding: 24px;
overflow: auto;
box-sizing: border-box;
}
.project-create-dialog__footer {

View File

@@ -4,6 +4,8 @@ import { useMediaQuery } from '@vueuse/core';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { objectContextDomainConfigs } from '@/constants/object-context';
import {
fetchBatchCreateProjectMembers,
fetchBatchInactiveProjectMembers,
fetchChangeProjectStatus,
fetchCreateProjectMember,
fetchDeleteProject,
@@ -18,8 +20,12 @@ import {
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
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 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 MemberRemoveDialog from './modules/member-remove-dialog.vue';
import ProjectDeleteDialog from './modules/project-delete-dialog.vue';
@@ -68,7 +74,11 @@ const pageLoading = ref(false);
const memberLoading = ref(false);
const baseInfoVisible = ref(false);
const memberOperateVisible = ref(false);
const memberBatchVisible = 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 deleteVisible = ref(false);
const memberOperateMode = ref<'create' | 'edit'>('create');
@@ -211,9 +221,7 @@ function scrollToSection(key: string) {
}
function openCreateMember() {
memberOperateMode.value = 'create';
selectedMember.value = null;
memberOperateVisible.value = true;
memberBatchVisible.value = true;
}
function openEditMember(member: Api.Project.ProjectMember) {
@@ -227,6 +235,12 @@ function openRemoveMember(member: Api.Project.ProjectMember) {
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) {
selectedAction.value = action;
statusActionVisible.value = true;
@@ -299,6 +313,53 @@ async function handleSubmitRemoveMember(payload: Api.Project.InactiveProjectMemb
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) {
if (!currentObjectId.value || !selectedAction.value) {
return;
@@ -387,6 +448,7 @@ watch(
<section :id="sectionIdMap.team" class="project-setting-page__section">
<SettingTeamPanel
ref="teamPanelRef"
:members="members"
:role-options="roleOptions"
:loading="memberLoading"
@@ -394,6 +456,7 @@ watch(
@create="openCreateMember"
@edit="openEditMember"
@remove="openRemoveMember"
@batch-remove="openBatchRemoveMember"
/>
</section>
@@ -432,6 +495,18 @@ watch(
:member="selectedMember"
@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
v-model:visible="statusActionVisible"
:action="selectedAction"

View File

@@ -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>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus';
import { filterProjectMembers, formatProjectMemberDate, getProjectTeamTableHeight } from '../shared';
defineOptions({ name: 'ProjectSettingTeamPanel' });
@@ -15,6 +16,7 @@ interface Emits {
(e: 'create'): void;
(e: 'edit', 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>(), {
@@ -26,19 +28,41 @@ const emit = defineEmits<Emits>();
const searchKeyword = ref('');
const selectedRoleId = ref('');
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 roleMap = new Map<string, string>();
const seen = new Set<string>();
const result: Api.SystemManage.RoleSimple[] = [];
props.roleOptions.forEach(role => {
if (!roleMap.has(role.id)) {
roleMap.set(role.id, role.name);
}
if (role.visible === 0) return;
if (seen.has(role.id)) return;
seen.add(role.id);
result.push(role);
});
return [...roleMap.entries()].map(([value, label]) => ({
value,
label
}));
return result;
});
const filteredMembers = computed(() =>
filterProjectMembers(props.members, {
@@ -49,7 +73,7 @@ const filteredMembers = computed(() =>
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
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 = '';
}
});
@@ -72,44 +96,46 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
</div>
<div class="setting-team-panel__toolbar">
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
<ElOption
v-for="option in roleFilterOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
<ElOption v-for="role in roleFilterOptions" :key="role.id" :label="role.name" :value="role.id">
<div class="setting-team-panel__role-option">
<span class="setting-team-panel__role-option-name">{{ role.name }}</span>
<ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
<icon-ep:info-filled class="setting-team-panel__role-option-info" @click.stop />
</ElTooltip>
</div>
</ElOption>
</ElSelect>
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
<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>
</template>
<ElTable
ref="tableRef"
v-loading="props.loading"
:data="filteredMembers"
:height="teamTableHeight"
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
border
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 prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn label="当前角色" min-width="180">
<template #default="{ row }">
<div class="setting-team-panel__role-cell">
<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>
{{ row.roleName || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="成员状态" width="110" align="center">
@@ -186,15 +212,31 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
gap: 12px;
}
.setting-team-panel__role-cell {
display: inline-flex;
.setting-team-panel__role-option {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.setting-team-panel__role-extra {
font-weight: 400;
.setting-team-panel__role-option-name {
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) {

File diff suppressed because it is too large Load Diff