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:
2026-04-23 09:05:55 +08:00
parent c5911ea34b
commit 4122dfa50d
95 changed files with 9581 additions and 801 deletions

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

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

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

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

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

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

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

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

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

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