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,115 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import { getProductStatusLabel, getProductStatusTagType } from './product-master-data';
import type { CurrentProductSummary } from './product-context-shared';
defineOptions({ name: 'ProductContextBanner' });
interface Props {
product: CurrentProductSummary | null;
caption: string;
}
const props = defineProps<Props>();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const productStatusCode = computed(() => props.product?.statusCode as Api.Product.ProductStatusCode | undefined);
const summaryItems = computed(() => {
if (!props.product) {
return [];
}
return [
{ label: '产品 ID', value: props.product.id || '--' },
{ label: '产品编码', value: props.product.code || '--' },
{ label: '产品方向', value: getDirectionLabel(props.product.directionCode, '--') },
{ label: '产品经理', value: props.product.managerUserId || '--' }
];
});
</script>
<template>
<ElCard class="product-context-banner card-wrapper">
<template v-if="product">
<div class="flex flex-col gap-20px lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="mb-12px flex flex-wrap items-center gap-10px">
<span class="product-context-banner__code">{{ product.code }}</span>
<ElTag :type="getProductStatusTagType(productStatusCode!)" effect="light" round>
{{ getProductStatusLabel(productStatusCode!) }}
</ElTag>
</div>
<div class="mb-10px flex flex-wrap items-center gap-12px">
<h2 class="text-24px text-[#0f172a] font-700">{{ product.name }}</h2>
<span class="text-14px text-[#64748b]">{{ caption }}</span>
</div>
<div class="flex flex-wrap gap-x-18px gap-y-8px text-13px text-[#64748b] leading-22px">
<span>对象 ID{{ product.id || '--' }}</span>
<span>方向{{ getDirectionLabel(product.directionCode, '--') }}</span>
<span>产品经理{{ product.managerUserId || '--' }}</span>
</div>
</div>
<div class="product-context-banner__stats">
<div v-for="item in summaryItems" :key="item.label" class="product-context-banner__stat-card">
<span class="product-context-banner__stat-label">{{ item.label }}</span>
<strong class="product-context-banner__stat-value">{{ item.value }}</strong>
</div>
</div>
</div>
</template>
<ElEmpty v-else description="未获取到当前产品上下文" :image-size="84" />
</ElCard>
</template>
<style scoped>
.product-context-banner {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 88%);
background:
radial-gradient(circle at top left, rgb(14 165 233 / 10%), transparent 32%),
linear-gradient(135deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
}
.product-context-banner__code {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background-color: rgb(15 23 42 / 88%);
color: #fff;
font-size: 12px;
letter-spacing: 0.08em;
}
.product-context-banner__stats {
display: grid;
flex-shrink: 0;
grid-template-columns: repeat(2, minmax(132px, 1fr));
gap: 12px;
width: min(100%, 320px);
}
.product-context-banner__stat-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border: 1px solid rgb(148 163 184 / 18%);
border-radius: 16px;
background-color: rgb(255 255 255 / 72%);
}
.product-context-banner__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-context-banner__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 20px;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,43 @@
export interface CurrentProductSummary {
id: string;
code: string;
directionCode: string;
name: string;
managerUserId: string;
statusCode: string;
}
export function resolveObjectIdFromQuery(
routeObjectId: string | null | Array<string | null> | undefined,
fallbackObjectId: string
) {
if (Array.isArray(routeObjectId)) {
return String(routeObjectId[0] || fallbackObjectId || '');
}
if (routeObjectId === null || routeObjectId === undefined || routeObjectId === '') {
return fallbackObjectId;
}
return String(routeObjectId);
}
export function normalizeCurrentProductSummary(
objectSummary: App.ObjectContext.Summary | null | undefined,
objectName: string
): CurrentProductSummary | null {
const currentProduct = objectSummary?.currentProduct;
if (!currentProduct || typeof currentProduct !== 'object') {
return null;
}
return {
id: String((currentProduct as Record<string, unknown>).id || ''),
code: String((currentProduct as Record<string, unknown>).code || ''),
directionCode: String((currentProduct as Record<string, unknown>).directionCode || ''),
name: String((currentProduct as Record<string, unknown>).name || objectName || ''),
managerUserId: String((currentProduct as Record<string, unknown>).managerUserId || ''),
statusCode: String((currentProduct as Record<string, unknown>).statusCode || '')
};
}

View File

@@ -0,0 +1,68 @@
import { transformRecordToOption } from '@/utils/common';
export const productStatusRecord: Record<Api.Product.ProductStatusCode, string> = {
active: '启用',
paused: '暂停',
archived: '归档',
abandoned: '废弃'
};
export const productStatusOptions = transformRecordToOption(productStatusRecord);
export const productStatusActionRecord: Record<Api.Product.ProductStatusActionCode, string> = {
pause: '暂停产品',
resume: '恢复产品',
archive: '归档产品',
abandon: '废弃产品'
};
export function getProductStatusLabel(status: Api.Product.ProductStatusCode) {
return productStatusRecord[status];
}
export function getProductStatusTagType(status: Api.Product.ProductStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Product.ProductStatusCode, UI.ThemeColor> = {
active: 'success',
paused: 'warning',
archived: 'info',
abandoned: 'danger'
};
return statusTagTypeMap[status];
}
export function isProductEditable(status: Api.Product.ProductStatusCode) {
return status === 'active' || status === 'paused';
}
export function isProductEditLimited(status: Api.Product.ProductStatusCode) {
return status === 'paused';
}
export function getAllowedProductStatusActions(
status: Api.Product.ProductStatusCode
): Api.Product.ProductStatusActionCode[] {
const actionMap: Record<Api.Product.ProductStatusCode, Api.Product.ProductStatusActionCode[]> = {
active: ['pause', 'archive', 'abandon'],
paused: ['resume', 'archive', 'abandon'],
archived: [],
abandoned: []
};
return actionMap[status];
}
export function getProductStatusActionLabel(actionCode: Api.Product.ProductStatusActionCode) {
return productStatusActionRecord[actionCode];
}
export function getProductStatusActionOptions(status: Api.Product.ProductStatusCode) {
return getAllowedProductStatusActions(status).map(actionCode => ({
value: actionCode,
label: getProductStatusActionLabel(actionCode)
}));
}
export function isProductActionReasonRequired(actionCode: Api.Product.ProductStatusActionCode) {
return actionCode !== 'resume';
}

View File

@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { useObjectContextStore } from '@/store/modules/object-context';
import { normalizeCurrentProductSummary, resolveObjectIdFromQuery } from './product-context-shared';
export function useCurrentProduct() {
const route = useRoute();
const objectContextStore = useObjectContextStore();
const currentObjectId = computed(() => {
return resolveObjectIdFromQuery(route.query[OBJECT_CONTEXT_QUERY_KEY], objectContextStore.objectId);
});
const currentProduct = computed(() =>
normalizeCurrentProductSummary(objectContextStore.objectSummary, objectContextStore.objectName)
);
return {
currentObjectId,
currentProduct
};
}