508 lines
14 KiB
Vue
508 lines
14 KiB
Vue
<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.filter(user => !members.some(member => member.status === 0 && member.userId === user.id))
|
|
"
|
|
@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>
|