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>
|
||||
Reference in New Issue
Block a user