feat(product): 新增产品管理模块与字典组件功能
- 新增产品管理相关路由和页面(dashboard、list、requirement、setting) - 实现产品基础信息编辑弹窗组件(base-info-dialog.vue) - 添加运行时字典功能(dict-select、dict-text、dict-tag组件) - 集成字典管理store和API调用 - 规范ID类型定义为string避免精度丢失问题 - 完善国际化资源文件支持中英文对照 - 新增对象上下文业务域入口页导航实现说明 - 添加Vue DevTools浮动入口注释说明 - 统一权限控制支持全局和对象作用域区分 - 规范分页查询参数类型定义与使用方式
This commit is contained in:
505
src/views/product/setting/index.vue
Normal file
505
src/views/product/setting/index.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import {
|
||||
fetchChangeProductStatus,
|
||||
fetchCreateProductMember,
|
||||
fetchDeleteProduct,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProductSettings,
|
||||
fetchGetRoleSimpleList,
|
||||
fetchGetUserSimpleList,
|
||||
fetchInactiveProductMember,
|
||||
fetchUpdateProductMember,
|
||||
fetchUpdateProductSettingBaseInfo
|
||||
} from '@/service/api';
|
||||
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 { useCurrentProduct } from '../shared/use-current-product';
|
||||
import BaseInfoDialog from './modules/base-info-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';
|
||||
import SettingAnchorNav from './modules/setting-anchor-nav.vue';
|
||||
import SettingBaseInfoCard from './modules/setting-base-info-card.vue';
|
||||
import SettingDangerZone from './modules/setting-danger-zone.vue';
|
||||
import SettingLifecyclePanel from './modules/setting-lifecycle-panel.vue';
|
||||
import SettingTeamPanel from './modules/setting-team-panel.vue';
|
||||
import StatusActionDialog from './modules/status-action-dialog.vue';
|
||||
import {
|
||||
type ProductSettingSectionKey,
|
||||
canManageProductTeam,
|
||||
getProductSettingSectionKeys,
|
||||
resolveVisibleProductSettingSectionKey,
|
||||
resolveVisibleProductSettingSections
|
||||
} from './shared';
|
||||
|
||||
defineOptions({ name: 'ProductSetting' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPush } = useRouterPush();
|
||||
const { currentObjectId, currentProduct } = useCurrentProduct();
|
||||
const isCompactLayout = useMediaQuery('(max-width: 1280px)');
|
||||
|
||||
const productDomainConfig = objectContextDomainConfigs.find(config => config.domainKey === 'product') || null;
|
||||
|
||||
const allAnchorItems = [
|
||||
{ key: 'base-info', label: '基础信息' },
|
||||
{ key: 'team', label: '团队管理' },
|
||||
{ key: 'lifecycle', label: '生命周期管理' },
|
||||
{ key: 'danger', label: '危险操作' }
|
||||
] as const;
|
||||
|
||||
const anchorLabelMap = new Map(allAnchorItems.map(item => [item.key, item.label]));
|
||||
|
||||
const sectionIdMap: Record<ProductSettingSectionKey, string> = {
|
||||
'base-info': 'product-setting-base-info',
|
||||
team: 'product-setting-team',
|
||||
lifecycle: 'product-setting-lifecycle',
|
||||
danger: 'product-setting-danger'
|
||||
};
|
||||
|
||||
const activeAnchorKey = ref<ProductSettingSectionKey>('base-info');
|
||||
const pageLoading = ref(false);
|
||||
const memberLoading = ref(false);
|
||||
const baseInfoVisible = ref(false);
|
||||
const memberOperateVisible = ref(false);
|
||||
const memberRemoveVisible = ref(false);
|
||||
const statusActionVisible = ref(false);
|
||||
const deleteVisible = ref(false);
|
||||
const memberOperateMode = ref<'create' | 'edit'>('create');
|
||||
const selectedMember = ref<Api.Product.ProductMember | null>(null);
|
||||
const selectedAction = ref<Api.Product.ProductLifecycleAction | null>(null);
|
||||
|
||||
const settings = ref<Api.Product.ProductSettings | null>(null);
|
||||
const members = ref<Api.Product.ProductMember[]>([]);
|
||||
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||
const userOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
|
||||
const currentManager = computed(() => members.value.find(item => item.managerFlag && item.status === 0) || null);
|
||||
const baseInfo = computed(() => settings.value?.baseInfo || null);
|
||||
const lifecycle = computed(() => settings.value?.lifecycle || null);
|
||||
const canManageTeam = computed(() =>
|
||||
canManageProductTeam({
|
||||
buttonCodes: objectContextStore.buttonCodes,
|
||||
loginUserId: authStore.userInfo.userId,
|
||||
currentManagerUserId: currentManager.value?.userId
|
||||
})
|
||||
);
|
||||
const visibleSectionKeys = computed(() =>
|
||||
resolveVisibleProductSettingSections(getProductSettingSectionKeys(), objectContextStore.buttonCodes)
|
||||
);
|
||||
const anchorItems = computed(() =>
|
||||
visibleSectionKeys.value.map(key => ({
|
||||
key,
|
||||
label: anchorLabelMap.get(key) || key
|
||||
}))
|
||||
);
|
||||
const layoutScrollTarget = `#${LAYOUT_SCROLL_EL_ID}`;
|
||||
const anchorAffixOffset = computed(() => {
|
||||
const fixedTopInset = themeStore.fixedHeaderAndTab
|
||||
? themeStore.header.height + (themeStore.tabVisible ? themeStore.tab.height : 0)
|
||||
: 0;
|
||||
|
||||
return fixedTopInset + 16;
|
||||
});
|
||||
const anchorShellInlineStyle = computed(() => ({
|
||||
maxHeight: isCompactLayout.value ? '' : `calc(100vh - ${anchorAffixOffset.value + 16}px)`
|
||||
}));
|
||||
const showLifecycleSection = computed(() => visibleSectionKeys.value.includes('lifecycle'));
|
||||
const showDangerSection = computed(() => visibleSectionKeys.value.includes('danger'));
|
||||
|
||||
async function loadSettings() {
|
||||
if (!currentObjectId.value) {
|
||||
settings.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProductSettings(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
settings.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
settings.value = data;
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
if (!currentObjectId.value) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
memberLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProductMembers(currentObjectId.value);
|
||||
|
||||
memberLoading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
members.value = data;
|
||||
}
|
||||
|
||||
async function loadRoleOptions() {
|
||||
const { error, data } = await fetchGetRoleSimpleList({
|
||||
scopeType: 'object',
|
||||
objectType: 'product'
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
roleOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
roleOptions.value = data;
|
||||
}
|
||||
|
||||
async function loadUserOptions() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
|
||||
if (error || !data) {
|
||||
userOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
userOptions.value = data;
|
||||
}
|
||||
|
||||
async function refreshContextSummary() {
|
||||
if (!productDomainConfig || !currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await objectContextStore.enterContext(productDomainConfig, currentObjectId.value);
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageLoading.value = true;
|
||||
|
||||
await Promise.all([loadSettings(), loadMembers(), loadRoleOptions(), loadUserOptions()]);
|
||||
|
||||
pageLoading.value = false;
|
||||
}
|
||||
|
||||
function scrollToSection(key: string) {
|
||||
if (!(key in sectionIdMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedKey = key as ProductSettingSectionKey;
|
||||
|
||||
if (!visibleSectionKeys.value.includes(resolvedKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeAnchorKey.value = resolvedKey;
|
||||
const target = document.getElementById(sectionIdMap[resolvedKey]);
|
||||
|
||||
target?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateMember() {
|
||||
memberOperateMode.value = 'create';
|
||||
selectedMember.value = null;
|
||||
memberOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditMember(member: Api.Product.ProductMember) {
|
||||
memberOperateMode.value = 'edit';
|
||||
selectedMember.value = member;
|
||||
memberOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openRemoveMember(member: Api.Product.ProductMember) {
|
||||
selectedMember.value = member;
|
||||
memberRemoveVisible.value = true;
|
||||
}
|
||||
|
||||
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
|
||||
selectedAction.value = action;
|
||||
statusActionVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmitBaseInfo(payload: Api.Product.UpdateProductSettingBaseInfoParams) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchUpdateProductSettingBaseInfo(currentObjectId.value, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('基础信息更新成功');
|
||||
baseInfoVisible.value = false;
|
||||
|
||||
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||
}
|
||||
|
||||
async function handleSubmitMemberOperate(event: {
|
||||
mode: 'create' | 'edit';
|
||||
memberId?: string;
|
||||
managerChanged: boolean;
|
||||
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
|
||||
}) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result =
|
||||
event.mode === 'create'
|
||||
? await fetchCreateProductMember(currentObjectId.value, event.payload as Api.Product.CreateProductMemberParams)
|
||||
: await fetchUpdateProductMember(
|
||||
currentObjectId.value,
|
||||
event.memberId || '',
|
||||
event.payload as Api.Product.UpdateProductMemberParams
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(event.mode === 'create' ? '成员新增成功' : '成员角色调整成功');
|
||||
memberOperateVisible.value = false;
|
||||
|
||||
await Promise.all([loadMembers(), loadSettings()]);
|
||||
|
||||
if (event.managerChanged) {
|
||||
await refreshContextSummary();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
|
||||
if (!currentObjectId.value || !selectedMember.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchInactiveProductMember(currentObjectId.value, selectedMember.value.id, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('成员移出成功');
|
||||
memberRemoveVisible.value = false;
|
||||
|
||||
await Promise.all([loadMembers(), loadSettings()]);
|
||||
}
|
||||
|
||||
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
|
||||
if (!currentObjectId.value || !selectedAction.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchChangeProductStatus({
|
||||
...payload,
|
||||
id: currentObjectId.value
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(`${selectedAction.value.actionName}成功`);
|
||||
statusActionVisible.value = false;
|
||||
|
||||
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||
}
|
||||
|
||||
async function handleSubmitDelete(payload: Api.Product.DeleteProductParams) {
|
||||
const result = await fetchDeleteProduct(payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('产品删除成功');
|
||||
deleteVisible.value = false;
|
||||
objectContextStore.clearContext();
|
||||
|
||||
await routerPush({
|
||||
path: '/product/list'
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
visibleSectionKeys,
|
||||
sectionKeys => {
|
||||
activeAnchorKey.value = resolveVisibleProductSettingSectionKey(activeAnchorKey.value, sectionKeys);
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async objectId => {
|
||||
if (!objectId) {
|
||||
settings.value = null;
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPageData();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="product-setting-page">
|
||||
<div class="product-setting-page__body">
|
||||
<div class="product-setting-page__aside">
|
||||
<div v-if="isCompactLayout" class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||
</div>
|
||||
<ElAffix
|
||||
v-else
|
||||
class="product-setting-page__aside-affix"
|
||||
:offset="anchorAffixOffset"
|
||||
:target="layoutScrollTarget"
|
||||
teleported
|
||||
>
|
||||
<div class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||
</div>
|
||||
</ElAffix>
|
||||
</div>
|
||||
|
||||
<div class="product-setting-page__content">
|
||||
<section :id="sectionIdMap['base-info']" class="product-setting-page__section">
|
||||
<SettingBaseInfoCard :base-info="baseInfo" @edit="baseInfoVisible = true" />
|
||||
</section>
|
||||
|
||||
<section :id="sectionIdMap.team" class="product-setting-page__section">
|
||||
<SettingTeamPanel
|
||||
:members="members"
|
||||
:role-options="roleOptions"
|
||||
:loading="memberLoading"
|
||||
:readonly="!canManageTeam"
|
||||
@create="openCreateMember"
|
||||
@edit="openEditMember"
|
||||
@remove="openRemoveMember"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="showLifecycleSection" :id="sectionIdMap.lifecycle" class="product-setting-page__section">
|
||||
<SettingLifecyclePanel :lifecycle="lifecycle" @action="openLifecycleAction" />
|
||||
</section>
|
||||
|
||||
<section v-if="showDangerSection" :id="sectionIdMap.danger" class="product-setting-page__section">
|
||||
<SettingDangerZone
|
||||
:product-name="baseInfo?.name || currentProduct?.name || ''"
|
||||
@delete="deleteVisible = true"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseInfoDialog v-model:visible="baseInfoVisible" :base-info="baseInfo" @submit="handleSubmitBaseInfo" />
|
||||
<MemberOperateDialog
|
||||
v-model:visible="memberOperateVisible"
|
||||
:mode="memberOperateMode"
|
||||
:member="selectedMember"
|
||||
:current-manager="currentManager"
|
||||
:role-options="roleOptions"
|
||||
:user-options="userOptions"
|
||||
@submit="handleSubmitMemberOperate"
|
||||
/>
|
||||
<MemberRemoveDialog
|
||||
v-model:visible="memberRemoveVisible"
|
||||
:member="selectedMember"
|
||||
@submit="handleSubmitRemoveMember"
|
||||
/>
|
||||
<StatusActionDialog
|
||||
v-model:visible="statusActionVisible"
|
||||
:action="selectedAction"
|
||||
@submit="handleSubmitLifecycleAction"
|
||||
/>
|
||||
<ProductDeleteDialog
|
||||
v-model:visible="deleteVisible"
|
||||
:product-id="currentObjectId"
|
||||
:product-name="baseInfo?.name || currentProduct?.name || ''"
|
||||
@submit="handleSubmitDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-setting-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-setting-page__body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-setting-page__aside {
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-affix {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-shell {
|
||||
min-height: 100%;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 7%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.product-setting-page__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-setting-page__section {
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-setting-page__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-shell {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
src/views/product/setting/modules/base-info-dialog.vue
Normal file
218
src/views/product/setting/modules/base-info-dialog.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import { getProductBaseInfoReadonlyMessage, isProductBaseInfoEditable } from '../shared';
|
||||
|
||||
defineOptions({ name: 'BaseInfoDialog' });
|
||||
|
||||
interface Props {
|
||||
baseInfo: Api.Product.ProductSettingBaseInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.UpdateProductSettingBaseInfoParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const model = reactive<Api.Product.UpdateProductSettingBaseInfoParams>({
|
||||
directionCode: '',
|
||||
name: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
const baseInfoEditable = computed(() => isProductBaseInfoEditable(props.baseInfo?.statusCode));
|
||||
const readonlyMessage = computed(() => getProductBaseInfoReadonlyMessage(props.baseInfo?.statusCode));
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
if (!props.baseInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !baseInfoEditable.value;
|
||||
});
|
||||
|
||||
const directionDisplayName = computed(() => {
|
||||
const directionCode = props.baseInfo?.directionCode;
|
||||
|
||||
if (!directionCode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getDirectionLabel(directionCode, directionCode);
|
||||
});
|
||||
|
||||
const rules = {
|
||||
directionCode: [createRequiredRule('请选择产品方向')],
|
||||
name: [createRequiredRule('请输入产品名称')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
async function handleConfirm() {
|
||||
if (confirmDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
emit('submit', {
|
||||
directionCode: model.directionCode,
|
||||
name: model.name.trim(),
|
||||
description: model.description?.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value || !props.baseInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.directionCode = props.baseInfo.directionCode || '';
|
||||
model.name = props.baseInfo.name || '';
|
||||
model.description = props.baseInfo.description || '';
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="编辑基础信息"
|
||||
preset="lg"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert v-if="readonlyMessage" :title="readonlyMessage" type="warning" :closable="false" class="mb-16px" />
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品编码">
|
||||
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem>
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<ElTooltip
|
||||
content="如需调整产品经理,请到产品内的团队管理处处理。"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
<span class="business-form-label-tip">
|
||||
<icon-fe:question />
|
||||
</span>
|
||||
</ElTooltip>
|
||||
<span>产品经理</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElInput
|
||||
:model-value="baseInfo?.managerUserNickname || baseInfo?.managerUserId || ''"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品名称" prop="name">
|
||||
<ElInput v-if="baseInfoEditable" v-model="model.name" maxlength="128" placeholder="请输入产品名称" />
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="model.name"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未获取到产品名称"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品方向" prop="directionCode">
|
||||
<DictSelect
|
||||
v-if="baseInfoEditable"
|
||||
v-model="model.directionCode"
|
||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||
filterable
|
||||
placeholder="请选择产品方向"
|
||||
/>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="directionDisplayName"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未获取到产品方向"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="产品描述">
|
||||
<ElInput
|
||||
v-if="baseInfoEditable"
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未填写产品描述"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.base-info-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;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||
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;
|
||||
cursor: default;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-input__wrapper:hover),
|
||||
:deep(.base-info-dialog__readonly-input.is-focus .el-input__wrapper),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner:hover),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner:focus) {
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-input__inner),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: default;
|
||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
</style>
|
||||
203
src/views/product/setting/modules/member-operate-dialog.vue
Normal file
203
src/views/product/setting/modules/member-operate-dialog.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
|
||||
|
||||
defineOptions({ name: 'MemberOperateDialog' });
|
||||
|
||||
type OperateMode = 'create' | 'edit';
|
||||
|
||||
interface Props {
|
||||
mode: OperateMode;
|
||||
member: Api.Product.ProductMember | null;
|
||||
currentManager: Api.Product.ProductMember | null;
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
userOptions: Api.SystemManage.UserSimple[];
|
||||
}
|
||||
|
||||
interface SubmitPayload {
|
||||
mode: OperateMode;
|
||||
memberId?: string;
|
||||
managerChanged: boolean;
|
||||
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: SubmitPayload): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
interface Model {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
remark: string;
|
||||
reason: string;
|
||||
previousManagerRoleId: string;
|
||||
}
|
||||
|
||||
const model = reactive<Model>({
|
||||
userId: '',
|
||||
roleId: '',
|
||||
remark: '',
|
||||
reason: '',
|
||||
previousManagerRoleId: ''
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
|
||||
const selectedUserId = computed(() => (props.mode === 'create' ? model.userId : props.member?.userId || ''));
|
||||
const showManagerHandover = computed(() => {
|
||||
return (
|
||||
shouldRequireManagerHandover(model.roleId, props.currentManager) &&
|
||||
Boolean(selectedUserId.value) &&
|
||||
selectedUserId.value !== props.currentManager?.userId
|
||||
);
|
||||
});
|
||||
const previousManagerRoleOptions = computed(() =>
|
||||
getPreviousManagerRoleOptions(props.roleOptions, props.currentManager?.roleId || '')
|
||||
);
|
||||
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [item.id, item.nickname])));
|
||||
|
||||
const rules = computed(
|
||||
() =>
|
||||
({
|
||||
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
|
||||
roleId: [createRequiredRule('请选择角色')],
|
||||
previousManagerRoleId: showManagerHandover.value ? [createRequiredRule('请选择原产品经理交接后角色')] : []
|
||||
}) satisfies Record<string, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
async function handleConfirm() {
|
||||
await validate();
|
||||
|
||||
const sharedPayload = {
|
||||
roleId: model.roleId,
|
||||
remark: model.remark.trim() || null,
|
||||
previousManagerUserId: showManagerHandover.value ? props.currentManager?.userId || null : null,
|
||||
previousManagerRoleId: showManagerHandover.value ? model.previousManagerRoleId : null
|
||||
};
|
||||
|
||||
if (props.mode === 'create') {
|
||||
emit('submit', {
|
||||
mode: 'create',
|
||||
managerChanged: showManagerHandover.value,
|
||||
payload: {
|
||||
userId: model.userId,
|
||||
roleId: sharedPayload.roleId,
|
||||
remark: sharedPayload.remark,
|
||||
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
mode: 'edit',
|
||||
memberId: props.member?.id,
|
||||
managerChanged: showManagerHandover.value,
|
||||
payload: {
|
||||
roleId: sharedPayload.roleId,
|
||||
remark: sharedPayload.remark,
|
||||
reason: model.reason.trim() || null,
|
||||
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.userId = props.mode === 'create' ? '' : props.member?.userId || '';
|
||||
model.roleId = props.mode === 'create' ? '' : props.member?.roleId || '';
|
||||
model.remark = props.mode === 'create' ? '' : props.member?.remark || '';
|
||||
model.reason = '';
|
||||
model.previousManagerRoleId = '';
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" @confirm="handleConfirm">
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<BusinessFormSection title="成员信息">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
||||
<ElSelect v-model="model.userId" class="w-full" filterable placeholder="请选择成员用户">
|
||||
<ElOption v-for="item in userOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else label="成员用户">
|
||||
<ElInput :model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''" readonly />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="目标角色" prop="roleId">
|
||||
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="备注">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="mode === 'edit'" title="角色调整说明">
|
||||
<ElFormItem label="变更原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入变更原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="showManagerHandover" title="产品经理交接">
|
||||
<ElAlert
|
||||
:title="`当前产品经理 ${currentManager?.userNickname || currentManager?.userId || ''} 将完成交接,请选择其交接后角色。`"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElFormItem label="原产品经理交接后角色" prop="previousManagerRoleId">
|
||||
<ElSelect v-model="model.previousManagerRoleId" class="w-full" placeholder="请选择原产品经理交接后角色">
|
||||
<ElOption v-for="item in previousManagerRoleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
65
src/views/product/setting/modules/member-remove-dialog.vue
Normal file
65
src/views/product/setting/modules/member-remove-dialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'MemberRemoveDialog' });
|
||||
|
||||
interface Props {
|
||||
member: Api.Product.ProductMember | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.InactiveProductMemberParams): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
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="`确认将 ${member?.userNickname || member?.userId || '--'} 从当前产品团队中移出吗?`"
|
||||
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>
|
||||
84
src/views/product/setting/modules/product-delete-dialog.vue
Normal file
84
src/views/product/setting/modules/product-delete-dialog.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'ProductDeleteDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.DeleteProductParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
confirmName: '',
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
return !model.reason.trim() || model.confirmName.trim() !== props.productName;
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
emit('submit', {
|
||||
id: props.productId,
|
||||
productName: model.confirmName.trim(),
|
||||
reason: model.reason.trim()
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.confirmName = '';
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="删除产品"
|
||||
preset="sm"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
confirm-text="确认删除"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert
|
||||
:title="`请输入当前产品名称 ${productName || '--'} 完成二次确认,删除后将退出当前对象上下文。`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="删除确认名称">
|
||||
<ElInput v-model="model.confirmName" placeholder="请输入当前产品名称" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="删除原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入删除原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
90
src/views/product/setting/modules/setting-anchor-nav.vue
Normal file
90
src/views/product/setting/modules/setting-anchor-nav.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SettingAnchorNav' });
|
||||
|
||||
interface SettingAnchorItem {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: readonly SettingAnchorItem[];
|
||||
activeKey: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', key: string): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setting-anchor-nav">
|
||||
<div class="setting-anchor-nav__title">设置目录</div>
|
||||
<div class="setting-anchor-nav__list">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="setting-anchor-nav__item"
|
||||
:class="{ 'is-active': item.key === activeKey }"
|
||||
@click="emit('select', item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-anchor-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__title {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 56%);
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item.is-active {
|
||||
border-color: rgb(13 148 136 / 42%);
|
||||
background-color: rgb(240 253 250 / 98%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
78
src/views/product/setting/modules/setting-base-info-card.vue
Normal file
78
src/views/product/setting/modules/setting-base-info-card.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
|
||||
import { isProductBaseInfoEditable } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingBaseInfoCard' });
|
||||
|
||||
interface Props {
|
||||
baseInfo: Api.Product.ProductSettingBaseInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const editDisabled = computed(() => {
|
||||
if (!props.baseInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isProductBaseInfoEditable(props.baseInfo.statusCode);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">基础信息</h3>
|
||||
</div>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="editDisabled"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
编辑基础信息
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElDescriptions v-if="baseInfo" :column="2" border>
|
||||
<ElDescriptionsItem label="产品编码">{{ baseInfo.code || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品名称">{{ baseInfo.name || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品方向">
|
||||
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="baseInfo.directionCode" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品经理">
|
||||
{{ baseInfo.managerUserNickname || baseInfo.managerUserId || '--' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="当前状态">
|
||||
<ElTag :type="getProductStatusTagType(baseInfo.statusCode)">
|
||||
{{ getProductStatusLabel(baseInfo.statusCode) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近状态原因">{{ baseInfo.lastStatusReason || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品描述" :span="2">
|
||||
<div class="setting-base-info-card__description">{{ baseInfo.description || '--' }}</div>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElEmpty v-else description="未获取到基础信息" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-base-info-card__description {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
53
src/views/product/setting/modules/setting-danger-zone.vue
Normal file
53
src/views/product/setting/modules/setting-danger-zone.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SettingDangerZone' });
|
||||
|
||||
interface Props {
|
||||
productName: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'delete'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="setting-danger-zone card-wrapper">
|
||||
<div class="setting-danger-zone__content">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-16px text-[#7f1d1d] font-700">危险操作</h3>
|
||||
<p class="mt-8px text-14px text-[#991b1b] leading-24px">
|
||||
删除后将退出当前产品对象上下文,并返回产品入口页。删除时必须输入当前产品名称
|
||||
<strong>{{ productName || '--' }}</strong>
|
||||
进行二次确认。
|
||||
</p>
|
||||
</div>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:delete', source: 'object' }"
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('delete')"
|
||||
>
|
||||
删除产品
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-danger-zone {
|
||||
border: 1px solid rgb(254 202 202 / 96%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(254 226 226 / 96%), transparent 35%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(254 242 242 / 96%));
|
||||
}
|
||||
|
||||
.setting-danger-zone__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
393
src/views/product/setting/modules/setting-lifecycle-panel.vue
Normal file
393
src/views/product/setting/modules/setting-lifecycle-panel.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getProductStatusLabel } from '../../shared/product-master-data';
|
||||
import { getProductLifecycleActionCardMeta, getProductLifecycleStatusSummary } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingLifecyclePanel' });
|
||||
|
||||
interface Props {
|
||||
lifecycle: Api.Product.ProductLifecycleInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: Api.Product.ProductLifecycleAction): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
if (!props.lifecycle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getProductLifecycleStatusSummary(props.lifecycle.statusCode);
|
||||
});
|
||||
|
||||
const actionCards = computed(() =>
|
||||
(props.lifecycle?.availableActions || []).map(action => ({
|
||||
...action,
|
||||
...getProductLifecycleActionCardMeta(action.actionCode)
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">生命周期管理</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="lifecycle">
|
||||
<div class="setting-lifecycle-panel__layout">
|
||||
<section
|
||||
class="setting-lifecycle-panel__hero"
|
||||
:class="[`setting-lifecycle-panel__hero--${statusSummary?.tone || 'slate'}`]"
|
||||
>
|
||||
<div class="setting-lifecycle-panel__hero-top">
|
||||
<div class="setting-lifecycle-panel__hero-main">
|
||||
<div class="setting-lifecycle-panel__hero-status-row">
|
||||
<span class="setting-lifecycle-panel__hero-status-label">当前状态</span>
|
||||
<span class="setting-lifecycle-panel__hero-status-chip">
|
||||
{{ getProductStatusLabel(lifecycle.statusCode) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="setting-lifecycle-panel__hero-title">{{ statusSummary?.caption }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="setting-lifecycle-panel__hero-desc">
|
||||
{{ statusSummary?.description }}
|
||||
</p>
|
||||
|
||||
<div class="setting-lifecycle-panel__reason-card">
|
||||
<span class="setting-lifecycle-panel__reason-label">最近状态原因</span>
|
||||
<strong class="setting-lifecycle-panel__reason-value">
|
||||
{{ lifecycle.lastStatusReason || '当前没有记录状态原因。' }}
|
||||
</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="setting-lifecycle-panel__action-panel">
|
||||
<div class="setting-lifecycle-panel__action-head">
|
||||
<h4 class="setting-lifecycle-panel__action-title">可执行动作</h4>
|
||||
</div>
|
||||
|
||||
<div v-if="actionCards.length > 0" class="setting-lifecycle-panel__action-grid">
|
||||
<button
|
||||
v-for="action in actionCards"
|
||||
:key="action.actionCode"
|
||||
type="button"
|
||||
class="setting-lifecycle-panel__action-card"
|
||||
:class="[`setting-lifecycle-panel__action-card--${action.tone}`]"
|
||||
@click="emit('action', action)"
|
||||
>
|
||||
<div class="setting-lifecycle-panel__action-card-top">
|
||||
<span class="setting-lifecycle-panel__action-dot" aria-hidden="true"></span>
|
||||
<strong class="setting-lifecycle-panel__action-name">{{ action.actionName }}</strong>
|
||||
</div>
|
||||
<p class="setting-lifecycle-panel__action-desc">{{ action.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="setting-lifecycle-panel__empty-tip">当前状态下暂无可执行生命周期动作。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-else description="未获取到生命周期信息" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-lifecycle-panel__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero,
|
||||
.setting-lifecycle-panel__action-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 20px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--emerald {
|
||||
border-color: rgb(16 185 129 / 22%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--amber {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(245 158 11 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 251 235 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--slate {
|
||||
border-color: rgb(100 116 139 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(100 116 139 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--rose {
|
||||
border-color: rgb(244 63 94 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(244 63 94 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 241 242 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-top,
|
||||
.setting-lifecycle-panel__action-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-label {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-desc {
|
||||
max-width: 560px;
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 82%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-value {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-panel {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(59 130 246 / 7%), transparent 32%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 96%);
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgb(15 23 42 / 6%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background-color: currentcolor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-name {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-desc {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__empty-tip {
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed rgb(203 213 225 / 92%);
|
||||
border-radius: 16px;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--emerald .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(16 185 129 / 24%);
|
||||
background-color: rgb(236 253 245 / 90%);
|
||||
color: rgb(4 120 87 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--amber .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(245 158 11 / 24%);
|
||||
background-color: rgb(255 247 237 / 94%);
|
||||
color: rgb(180 83 9 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--slate .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(148 163 184 / 28%);
|
||||
background-color: rgb(241 245 249 / 94%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--rose .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(244 63 94 / 24%);
|
||||
background-color: rgb(255 241 242 / 94%);
|
||||
color: rgb(190 24 93 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald {
|
||||
border-color: rgb(16 185 129 / 22%);
|
||||
background: linear-gradient(90deg, rgb(236 253 245 / 90%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(4 120 87 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
background: linear-gradient(90deg, rgb(255 247 237 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(180 83 9 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate {
|
||||
border-color: rgb(148 163 184 / 26%);
|
||||
background: linear-gradient(90deg, rgb(241 245 249 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose {
|
||||
border-color: rgb(244 63 94 / 22%);
|
||||
background: linear-gradient(90deg, rgb(255 241 242 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(190 24 93 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald:hover {
|
||||
box-shadow: 0 10px 22px rgb(16 185 129 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber:hover {
|
||||
box-shadow: 0 10px 22px rgb(245 158 11 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate:hover {
|
||||
box-shadow: 0 10px 22px rgb(100 116 139 / 10%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose:hover {
|
||||
box-shadow: 0 10px 22px rgb(244 63 94 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald .setting-lifecycle-panel__action-name {
|
||||
color: rgb(6 95 70 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber .setting-lifecycle-panel__action-name {
|
||||
color: rgb(146 64 14 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate .setting-lifecycle-panel__action-name {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose .setting-lifecycle-panel__action-name {
|
||||
color: rgb(159 18 57 / 96%);
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.setting-lifecycle-panel__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.setting-lifecycle-panel__hero-top,
|
||||
.setting-lifecycle-panel__action-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
src/views/product/setting/modules/setting-team-panel.vue
Normal file
201
src/views/product/setting/modules/setting-team-panel.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingTeamPanel' });
|
||||
|
||||
interface Props {
|
||||
members: Api.Product.ProductMember[];
|
||||
roleOptions?: Api.SystemManage.RoleSimple[];
|
||||
loading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', member: Api.Product.ProductMember): void;
|
||||
(e: 'remove', member: Api.Product.ProductMember): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
readonly: false,
|
||||
roleOptions: () => []
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const searchKeyword = ref('');
|
||||
const selectedRoleId = ref('');
|
||||
const teamTableHeight = getProductTeamTableHeight(5);
|
||||
const roleFilterOptions = computed(() => {
|
||||
const roleMap = new Map<string, string>();
|
||||
|
||||
props.roleOptions.forEach(role => {
|
||||
if (!roleMap.has(role.id)) {
|
||||
roleMap.set(role.id, role.name);
|
||||
}
|
||||
});
|
||||
|
||||
return [...roleMap.entries()].map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}));
|
||||
});
|
||||
const filteredMembers = computed(() =>
|
||||
filterProductMembers(props.members, {
|
||||
keyword: searchKeyword.value,
|
||||
roleId: selectedRoleId.value
|
||||
})
|
||||
);
|
||||
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
||||
|
||||
watch(roleFilterOptions, options => {
|
||||
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
|
||||
selectedRoleId.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
function getMemberStatusLabel(status: Api.Product.ProductMemberStatus) {
|
||||
return status === 0 ? '有效' : '失效';
|
||||
}
|
||||
|
||||
function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
|
||||
return status === 0 ? 'success' : 'info';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div class="setting-team-panel__header">
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">团队管理</h3>
|
||||
</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"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
||||
<ElButton
|
||||
v-if="!props.readonly"
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
type="primary"
|
||||
plain
|
||||
@click="emit('create')"
|
||||
>
|
||||
新增成员
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTable
|
||||
v-loading="props.loading"
|
||||
:data="filteredMembers"
|
||||
:height="teamTableHeight"
|
||||
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
||||
border
|
||||
row-key="id"
|
||||
>
|
||||
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
||||
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
|
||||
<ElTableColumn label="成员状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="joinedTime" label="加入时间" min-width="132" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatProductMemberDate(row.joinedTime) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="leftTime" label="退出时间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatProductMemberDate(row.leftTime) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.remark || '--' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!props.readonly" label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="setting-team-panel__actions">
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="row.status !== 0 || row.managerFlag"
|
||||
@click="emit('edit', row)"
|
||||
>
|
||||
调整角色
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
link
|
||||
type="danger"
|
||||
:disabled="row.status !== 0 || row.managerFlag"
|
||||
@click="emit('remove', row)"
|
||||
>
|
||||
移出成员
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-team-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-team-panel__toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-team-panel__search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.setting-team-panel__role-filter {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.setting-team-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.setting-team-panel__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-team-panel__toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-team-panel__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-team-panel__role-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
src/views/product/setting/modules/status-action-dialog.vue
Normal file
73
src/views/product/setting/modules/status-action-dialog.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'StatusActionDialog' });
|
||||
|
||||
interface Props {
|
||||
action: Api.Product.ProductLifecycleAction | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.ChangeProductStatusParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => Boolean(props.action?.needReason && !model.reason.trim()));
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
id: '',
|
||||
actionCode: props.action.actionCode,
|
||||
reason: model.reason.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="action ? `${action.actionName}产品` : '生命周期动作'"
|
||||
preset="sm"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem :label="action?.needReason ? '动作原因(必填)' : '动作原因(选填)'">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入动作原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
233
src/views/product/setting/shared.ts
Normal file
233
src/views/product/setting/shared.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export interface ProductManagerMemberLike {
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
interface ProductTeamManageContext {
|
||||
buttonCodes: readonly string[];
|
||||
loginUserId: string | null | undefined;
|
||||
currentManagerUserId: string | null | undefined;
|
||||
}
|
||||
|
||||
interface ProductLifecycleStatusSummary {
|
||||
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||
caption: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ProductLifecycleActionCardMeta {
|
||||
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const productSettingSectionKeys = ['base-info', 'team', 'lifecycle', 'danger'] as const;
|
||||
|
||||
export type ProductSettingSectionKey = (typeof productSettingSectionKeys)[number];
|
||||
|
||||
const productSettingSectionAuthCodeMap: Partial<Record<ProductSettingSectionKey, string>> = {
|
||||
lifecycle: 'project:product:status',
|
||||
danger: 'project:product:delete'
|
||||
};
|
||||
|
||||
const productBaseInfoReadonlyMessageMap: Partial<Record<Api.Product.ProductStatusCode, string>> = {
|
||||
paused: '当前产品已暂停,基础信息仅支持查看,不可编辑。',
|
||||
archived: '当前产品已归档,基础信息仅支持查看,不可编辑。',
|
||||
abandoned: '当前产品已废弃,基础信息仅支持查看,不可编辑。'
|
||||
};
|
||||
|
||||
const productLifecycleStatusSummaryMap: Record<Api.Product.ProductStatusCode, ProductLifecycleStatusSummary> = {
|
||||
active: {
|
||||
tone: 'emerald',
|
||||
caption: '产品正常服务中',
|
||||
description: '当前可以执行暂停、归档或废弃。'
|
||||
},
|
||||
paused: {
|
||||
tone: 'amber',
|
||||
caption: '产品已暂停推进',
|
||||
description: '条件恢复后可重新启用,也可继续归档或废弃。'
|
||||
},
|
||||
archived: {
|
||||
tone: 'slate',
|
||||
caption: '产品已收口归档',
|
||||
description: '保留历史信息,当前不再开放新的生命周期动作。'
|
||||
},
|
||||
abandoned: {
|
||||
tone: 'rose',
|
||||
caption: '产品已停止建设',
|
||||
description: '产品已结束推进,当前不再开放新的生命周期动作。'
|
||||
}
|
||||
};
|
||||
|
||||
const productLifecycleActionCardMetaMap: Record<Api.Product.ProductStatusActionCode, ProductLifecycleActionCardMeta> = {
|
||||
pause: {
|
||||
tone: 'amber',
|
||||
description: '暂停当前产品,后续仍可恢复或归档。'
|
||||
},
|
||||
resume: {
|
||||
tone: 'emerald',
|
||||
description: '恢复启用后,继续推进产品协作。'
|
||||
},
|
||||
archive: {
|
||||
tone: 'slate',
|
||||
description: '收口当前产品,保留历史记录并结束维护。'
|
||||
},
|
||||
abandon: {
|
||||
tone: 'rose',
|
||||
description: '终止当前产品建设,请谨慎确认。'
|
||||
}
|
||||
};
|
||||
|
||||
const productSettingErrorMessageMap: Record<string, string> = {
|
||||
'1008001002': '产品名称已存在,请更换名称',
|
||||
'1008001007': '当前产品状态不允许编辑基础信息',
|
||||
'1008001008': '当前产品已暂停,基础信息仅支持查看,不可编辑。',
|
||||
'1008001013': '请选择原产品经理交接后的角色',
|
||||
'1008001014': '当前产品经理不能直接移出,请先完成经理交接',
|
||||
'1008001015': '当前产品经理不能直接调整为非经理角色,请先完成经理转交',
|
||||
'1008001004': '当前状态不支持该动作',
|
||||
'1008001005': '当前动作必须填写原因',
|
||||
'1008001006': '删除确认名称与当前产品名称不一致'
|
||||
};
|
||||
|
||||
const productTeamTableHeaderHeight = 40;
|
||||
const productTeamTableRowHeight = 40;
|
||||
|
||||
export function shouldRequireManagerHandover(
|
||||
targetRoleId: string,
|
||||
currentManager: ProductManagerMemberLike | null | undefined
|
||||
) {
|
||||
if (!currentManager?.roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return targetRoleId === currentManager.roleId;
|
||||
}
|
||||
|
||||
export function getPreviousManagerRoleOptions(roleOptions: Api.SystemManage.RoleSimple[], managerRoleId: string) {
|
||||
return roleOptions.filter(role => role.id !== managerRoleId);
|
||||
}
|
||||
|
||||
export function getProductSettingSectionKeys() {
|
||||
return [...productSettingSectionKeys];
|
||||
}
|
||||
|
||||
export function isProductBaseInfoEditable(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
return status === 'active';
|
||||
}
|
||||
|
||||
export function resolveVisibleProductSettingSections(
|
||||
sectionKeys: readonly ProductSettingSectionKey[],
|
||||
buttonCodes: readonly string[]
|
||||
) {
|
||||
return sectionKeys.filter(sectionKey => {
|
||||
const authCode = productSettingSectionAuthCodeMap[sectionKey];
|
||||
|
||||
if (!authCode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return buttonCodes.includes(authCode);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVisibleProductSettingSectionKey(
|
||||
currentKey: ProductSettingSectionKey | string | null | undefined,
|
||||
visibleSectionKeys: readonly ProductSettingSectionKey[]
|
||||
) {
|
||||
if (!visibleSectionKeys.length) {
|
||||
return 'base-info' satisfies ProductSettingSectionKey;
|
||||
}
|
||||
|
||||
if (currentKey && visibleSectionKeys.includes(currentKey as ProductSettingSectionKey)) {
|
||||
return currentKey as ProductSettingSectionKey;
|
||||
}
|
||||
|
||||
return visibleSectionKeys[0];
|
||||
}
|
||||
|
||||
export function getProductBaseInfoReadonlyMessage(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
if (!status || isProductBaseInfoEditable(status)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return productBaseInfoReadonlyMessageMap[status] || '当前产品状态不允许编辑基础信息。';
|
||||
}
|
||||
|
||||
export function getProductLifecycleStatusSummary(status: Api.Product.ProductStatusCode) {
|
||||
return productLifecycleStatusSummaryMap[status];
|
||||
}
|
||||
|
||||
export function formatProductMemberDate(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const normalizedValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
||||
const parsedDate = dayjs(normalizedValue);
|
||||
|
||||
if (!parsedDate.isValid()) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return parsedDate.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
export function filterProductMembersByKeyword(
|
||||
members: readonly Api.Product.ProductMember[],
|
||||
keyword: string | null | undefined
|
||||
) {
|
||||
return filterProductMembers(members, { keyword });
|
||||
}
|
||||
|
||||
export function filterProductMembers(
|
||||
members: readonly Api.Product.ProductMember[],
|
||||
filters: {
|
||||
keyword?: string | null | undefined;
|
||||
roleId?: string | null | undefined;
|
||||
}
|
||||
) {
|
||||
const normalizedKeyword = String(filters.keyword || '')
|
||||
.trim()
|
||||
.toLocaleLowerCase();
|
||||
const normalizedRoleId = String(filters.roleId || '').trim();
|
||||
|
||||
if (!normalizedKeyword && !normalizedRoleId) {
|
||||
return [...members];
|
||||
}
|
||||
|
||||
return members.filter(member => {
|
||||
const matchesKeyword = !normalizedKeyword || member.userNickname.toLocaleLowerCase().includes(normalizedKeyword);
|
||||
const matchesRole = !normalizedRoleId || member.roleId === normalizedRoleId;
|
||||
|
||||
return matchesKeyword && matchesRole;
|
||||
});
|
||||
}
|
||||
|
||||
export function getProductTeamTableHeight(visibleRows: number) {
|
||||
const normalizedRows = Math.max(0, visibleRows);
|
||||
|
||||
return productTeamTableHeaderHeight + normalizedRows * productTeamTableRowHeight;
|
||||
}
|
||||
|
||||
export function getProductLifecycleActionCardMeta(actionCode: Api.Product.ProductStatusActionCode) {
|
||||
return productLifecycleActionCardMetaMap[actionCode];
|
||||
}
|
||||
|
||||
export function canManageProductTeam(context: ProductTeamManageContext) {
|
||||
const hasUpdateAuth = context.buttonCodes.includes('project:product:update');
|
||||
const loginUserId = String(context.loginUserId || '');
|
||||
const currentManagerUserId = String(context.currentManagerUserId || '');
|
||||
|
||||
if (!hasUpdateAuth || !loginUserId || !currentManagerUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return loginUserId === currentManagerUserId;
|
||||
}
|
||||
|
||||
export function getProductSettingErrorMessage(code: string | number | null | undefined, backendMessage: string) {
|
||||
const normalizedCode = String(code || '');
|
||||
|
||||
return productSettingErrorMessageMap[normalizedCode] || backendMessage;
|
||||
}
|
||||
Reference in New Issue
Block a user