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:
115
src/views/product/shared/product-context-banner.vue
Normal file
115
src/views/product/shared/product-context-banner.vue
Normal 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>
|
||||
43
src/views/product/shared/product-context-shared.ts
Normal file
43
src/views/product/shared/product-context-shared.ts
Normal 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 || '')
|
||||
};
|
||||
}
|
||||
68
src/views/product/shared/product-master-data.ts
Normal file
68
src/views/product/shared/product-master-data.ts
Normal 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';
|
||||
}
|
||||
23
src/views/product/shared/use-current-product.ts
Normal file
23
src/views/product/shared/use-current-product.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user