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,696 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref } from 'vue';
import type { Component } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { getProductStatusLabel, getProductStatusTagType, isProductEditable } from '../shared/product-master-data';
import ProductOperateDialog from './modules/product-operate-dialog.vue';
import ProductSearch from './modules/product-search.vue';
defineOptions({ name: 'ProductList' });
interface StatusNavMeta {
key: Api.Product.ProductStatusCode;
label: string;
description: string;
tone: 'teal' | 'slate' | 'amber' | 'rose';
icon: Component;
}
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
const PRODUCT_OPTION_PAGE_SIZE = 200;
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: '',
directionCode: undefined,
managerUserId: undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformProductPage(response: ProductPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
}
function formatDateTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
async function fetchProductTotal(params: Api.Product.ProductSearchParams) {
const { error, data } = await fetchGetProductPage({
...params,
pageNo: 1,
pageSize: 1
});
if (error || !data) {
return 0;
}
return data.total;
}
async function fetchAllProducts() {
async function collect(pageNo: number, list: Api.Product.Product[]): Promise<Api.Product.Product[] | null> {
const { error, data } = await fetchGetProductPage({
pageNo,
pageSize: PRODUCT_OPTION_PAGE_SIZE
});
if (error || !data) {
return null;
}
const nextList = list.concat(data.list);
if (nextList.length >= data.total || data.list.length === 0) {
return nextList;
}
return collect(pageNo + 1, nextList);
}
return collect(1, []);
}
function createManagerOptions(products: Api.Product.Product[], users: Api.SystemManage.UserSimple[]) {
const managerIdSet = new Set(products.map(item => String(item.managerUserId)).filter(Boolean));
const userMap = new Map(users.map(item => [String(item.id), item]));
const options = Array.from(managerIdSet).map(managerUserId => {
return (
userMap.get(managerUserId) || {
id: managerUserId,
nickname: String(managerUserId)
}
);
});
return sortManagerOptions(options);
}
const statusNavMetas: StatusNavMeta[] = [
{
key: 'active',
label: '启用产品',
description: '当前正常服务中的产品',
tone: 'teal',
icon: CircleCheckFilled
},
{
key: 'archived',
label: '归档产品',
description: '已完成阶段目标的产品',
tone: 'slate',
icon: FolderOpened
},
{
key: 'paused',
label: '暂停产品',
description: '阶段性暂停投入的产品',
tone: 'amber',
icon: VideoPause
},
{
key: 'abandoned',
label: '废弃产品',
description: '已明确停止建设的产品',
tone: 'rose',
icon: DeleteFilled
}
];
const searchParams = reactive(getInitSearchParams());
const selectedStatus = ref<Api.Product.ProductStatusCode>('active');
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
const operateVisible = ref(false);
const editingRow = ref<Api.Product.Product | null>(null);
const { routerPush } = useRouterPush();
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const statusCounts = ref<Record<Api.Product.ProductStatusCode, number>>({
active: 0,
archived: 0,
paused: 0,
abandoned: 0
});
const recentUpdatedCount = ref(0);
const managerLabelMap = computed(() => {
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
});
const statusItems = computed(() =>
statusNavMetas.map(item => ({
...item,
count: statusCounts.value[item.key]
}))
);
const overviewMetrics = computed(() => [
{
label: '可见产品',
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
hint: '当前接口可查询到的产品总量'
},
{
label: '当前启用',
value: statusCounts.value.active,
hint: '正在持续服务和维护的产品'
},
{
label: '产品方向',
value: directionOptions.value.length,
hint: '已加载的方向字典项数量'
},
{
label: '30天内更新',
value: recentUpdatedCount.value,
hint: '最近 30 天内发生过更新的产品'
}
]);
function getDirectionLabel(directionCode?: string | null) {
return getDirectionDictLabel(directionCode, '--');
}
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function createRequestParams(): Api.Product.ProductSearchParams {
return {
...searchParams,
keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value
};
}
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
ProductPageResponse,
Api.Product.Product
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetProductPage(createRequestParams()),
transform: response => transformProductPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'name',
label: '产品名称',
minWidth: 220,
formatter: row => (
<ElButton link type="primary" class="product-name-link" onClick={() => enterProductContext(row)}>
{row.name}
</ElButton>
)
},
{ prop: 'code', label: '产品编码', minWidth: 140, showOverflowTooltip: true },
{
prop: 'managerUserId',
label: '产品经理',
minWidth: 120,
formatter: row => getManagerLabel(row.managerUserId)
},
{
prop: 'directionCode',
label: '产品方向',
minWidth: 140,
showOverflowTooltip: true,
formatter: row => getDirectionLabel(row.directionCode)
},
{
prop: 'statusCode',
label: '管理状态',
width: 120,
align: 'center',
formatter: row => (
<ElTag type={getProductStatusTagType(row.statusCode)}>{getProductStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'lastStatusReason',
label: '状态原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: row => row.lastStatusReason?.trim() || '--'
},
{
prop: 'updateTime',
label: '最近更新',
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 108,
align: 'center',
fixed: 'right',
formatter: row => (
<BusinessTableActionCell
actions={[
{
key: 'edit',
label: '编辑',
buttonType: 'primary',
disabled: !isProductEditable(row.statusCode),
onClick: () => openEdit(row)
}
]}
/>
)
}
]
});
async function loadManagerOptions() {
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
const userSimpleList =
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
managerUserOptions.value = userSimpleList;
if (!allProducts) {
managerFilterOptions.value = [];
return;
}
managerFilterOptions.value = createManagerOptions(allProducts, userSimpleList);
}
async function loadOverviewData() {
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
const start = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
const [activeTotal, archivedTotal, pausedTotal, abandonedTotal, recentTotal] = await Promise.all([
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'active' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'archived' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'paused' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'abandoned' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, updateTime: [start, end] })
]);
statusCounts.value = {
active: activeTotal,
archived: archivedTotal,
paused: pausedTotal,
abandoned: abandonedTotal
};
recentUpdatedCount.value = recentTotal;
}
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
async function refreshPageData(page = searchParams.pageNo ?? 1) {
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProductTable(page)]);
}
async function handleSearch() {
await reloadProductTable(1);
}
async function handleResetSearch() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, getInitSearchParams(), {
pageSize
});
await reloadProductTable(1);
}
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
selectedStatus.value = status;
await reloadProductTable(1);
}
function openCreate() {
editingRow.value = null;
operateVisible.value = true;
}
function openEdit(row: Api.Product.Product) {
editingRow.value = row;
operateVisible.value = true;
}
async function enterProductContext(row: Api.Product.Product) {
await routerPush({
path: PRODUCT_ENTRY_ROUTE_PATH,
query: {
[OBJECT_CONTEXT_QUERY_KEY]: row.id
}
});
}
async function handleProductSubmitted(productId?: string) {
const isEditing = Boolean(productId && editingRow.value?.id === productId);
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
if (isEditing) {
editingRow.value = null;
}
}
onMounted(async () => {
await refreshPageData();
});
</script>
<template>
<div
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[396px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ElCard class="product-overview-card card-wrapper">
<div class="product-overview-card__stats">
<div v-for="item in overviewMetrics" :key="item.label" class="product-overview-card__stat">
<span class="product-overview-card__stat-label">{{ item.label }}</span>
<strong class="product-overview-card__stat-value">{{ item.value }}</strong>
<small class="product-overview-card__stat-hint">{{ item.hint }}</small>
</div>
</div>
<div class="product-status-panel__list">
<button
v-for="item in statusItems"
:key="item.key"
type="button"
class="product-status-item"
:class="[`product-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
:aria-pressed="selectedStatus === item.key"
@click="handleStatusChange(item.key)"
>
<div class="product-status-item__icon">
<ElIcon>
<component :is="item.icon" />
</ElIcon>
</div>
<div class="product-status-item__main">
<div class="product-status-item__top">
<strong>{{ item.label }}</strong>
<em>{{ item.count }}</em>
</div>
<p class="product-status-item__desc">{{ item.description }}</p>
</div>
</button>
</div>
</ElCard>
</div>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ProductSearch
v-model:model="searchParams"
:manager-options="managerFilterOptions"
@reset="handleResetSearch"
@search="handleSearch"
/>
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="product-table-card-body">
<template #header>
<div class="product-card-header">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-8px">
<p class="truncate text-16px font-600">产品列表</p>
<ElTag effect="plain" :type="getProductStatusTagType(selectedStatus)">
{{
statusItems.find(item => item.key === selectedStatus)?.label ||
getProductStatusLabel(selectedStatus)
}}
</ElTag>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="refreshPageData"
>
<template #default>
<ElButton plain type="primary" @click="openCreate">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
<template #empty>
<ElEmpty description="当前筛选条件下暂无产品" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
</div>
<ProductOperateDialog
v-model:visible="operateVisible"
:manager-user-options="managerUserOptions"
:row-data="editingRow"
@submitted="handleProductSubmitted"
/>
</div>
</template>
<style lang="scss" scoped>
.product-overview-card {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(15 118 110 / 8%), transparent 36%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.product-overview-card__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-overview-card__stat {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 84%);
}
.product-overview-card__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 13px;
}
.product-overview-card__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.product-overview-card__stat-hint {
color: rgb(100 116 139 / 90%);
font-size: 12px;
line-height: 1.5;
}
.product-status-panel__list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.product-status-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 18px;
background-color: rgb(255 255 255 / 86%);
text-align: left;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.product-status-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 60%);
}
.product-status-item.is-active {
border-color: rgb(15 118 110 / 40%);
box-shadow: 0 10px 24px rgb(15 118 110 / 8%);
}
.product-status-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 14px;
font-size: 20px;
}
.product-status-item__main {
min-width: 0;
flex: 1;
}
.product-status-item__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.product-status-item__top strong {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.product-status-item__top em {
color: rgb(15 23 42 / 88%);
font-size: 18px;
font-style: normal;
font-weight: 700;
}
.product-status-item__desc {
color: rgb(100 116 139 / 94%);
font-size: 13px;
line-height: 1.6;
}
.product-status-item--teal .product-status-item__icon {
background-color: rgb(240 253 250 / 96%);
color: rgb(15 118 110 / 96%);
}
.product-status-item--slate .product-status-item__icon {
background-color: rgb(241 245 249 / 96%);
color: rgb(51 65 85 / 92%);
}
.product-status-item--amber .product-status-item__icon {
background-color: rgb(255 251 235 / 96%);
color: rgb(217 119 6 / 92%);
}
.product-status-item--rose .product-status-item__icon {
background-color: rgb(255 241 242 / 96%);
color: rgb(225 29 72 / 92%);
}
:deep(.product-table-card-body) {
display: flex;
flex: 1;
flex-direction: column;
}
.product-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.product-name-link {
padding: 0;
}
@media (width <= 1280px) {
.product-card-header,
.product-status-item__top {
align-items: flex-start;
flex-direction: column;
}
}
@media (width <= 640px) {
.product-overview-card__stats {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchGetProduct } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictText from '@/components/custom/dict-text.vue';
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
defineOptions({ name: 'ProductDetailDialog' });
interface Props {
rowData?: Api.Product.Product | null;
managerOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const detailLoading = ref(false);
const detailData = ref<Api.Product.Product | null>(null);
const title = computed(() => {
return detailData.value?.name ? `产品详情 - ${detailData.value.name}` : '产品详情';
});
const managerLabelMap = computed(() => {
return new Map(props.managerOptions.map(item => [String(item.id), item.nickname]));
});
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function formatTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
async function initDetail() {
detailData.value = props.rowData ? { ...props.rowData } : null;
if (!props.rowData) {
return;
}
detailLoading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
detailLoading.value = false;
if (!error) {
detailData.value = data;
}
}
watch(visible, value => {
if (value) {
initDetail();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="lg"
:loading="detailLoading"
:show-footer="false"
:scrollbar="false"
>
<template v-if="detailData">
<div class="mb-16px flex flex-wrap items-center gap-8px">
<ElTag>{{ detailData.code }}</ElTag>
<ElTag :type="getProductStatusTagType(detailData.statusCode)">
{{ getProductStatusLabel(detailData.statusCode) }}
</ElTag>
</div>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="产品名称">{{ detailData.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品方向">
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="detailData.directionCode" />
</ElDescriptionsItem>
<ElDescriptionsItem label="产品经理">{{ getManagerLabel(detailData.managerUserId) }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ formatTime(detailData.createTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ formatTime(detailData.updateTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="最近状态原因">{{ detailData.lastStatusReason || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品描述" :span="2">
<span class="whitespace-pre-wrap">{{ detailData.description || '--' }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</template>
<ElEmpty v-else description="未获取到产品详情" />
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type DemoProduct, getProductHealthType, getProductStatusType } from '@/constants/product-demo';
defineOptions({ name: 'ProductEntryCard' });
interface Props {
product: DemoProduct;
entering?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
entering: false
});
interface Emits {
(e: 'enter', product: DemoProduct): void;
}
const emit = defineEmits<Emits>();
const quickFacts = computed(() => [
{ label: '版本', value: props.product.version },
{ label: '目标发版', value: props.product.releaseTarget },
{ label: '团队规模', value: `${props.product.teamCount}` }
]);
function handleEnter() {
emit('enter', props.product);
}
</script>
<template>
<ElCard class="product-entry-card h-full">
<div class="mb-14px flex items-start justify-between gap-12px">
<div class="min-w-0">
<div class="mb-8px flex flex-wrap items-center gap-8px">
<span class="product-entry-card__code">{{ product.code }}</span>
<ElTag :type="getProductStatusType(product.status)" round>{{ product.status }}</ElTag>
<ElTag :type="getProductHealthType(product.health)" effect="dark" round>{{ product.health }}</ElTag>
</div>
<h3 class="mb-6px text-18px text-[#0f172a] font-700">{{ product.name }}</h3>
<p class="text-13px text-[#64748b]">负责人{{ product.owner }} / 阶段{{ product.stage }}</p>
</div>
<div class="product-entry-card__pulse"></div>
</div>
<p class="mb-14px min-h-[66px] text-14px text-[#475569] leading-22px">{{ product.summary }}</p>
<div class="mb-14px flex flex-wrap gap-8px">
<ElTag v-for="tag in product.tags" :key="tag" effect="plain" round>{{ tag }}</ElTag>
</div>
<div class="grid mb-14px gap-10px sm:grid-cols-3">
<div v-for="item in quickFacts" :key="item.label" class="product-entry-card__fact">
<span class="product-entry-card__fact-label">{{ item.label }}</span>
<strong class="product-entry-card__fact-value">{{ item.value }}</strong>
</div>
</div>
<div class="mb-16px rounded-16px bg-[#f8fafc] p-12px">
<p class="mb-8px text-12px text-[#94a3b8] tracking-[0.08em] uppercase">当前聚焦</p>
<div class="flex flex-wrap gap-8px">
<span v-for="item in product.focus" :key="item" class="product-entry-card__focus-chip">
{{ item }}
</span>
</div>
</div>
<div class="flex items-center justify-between gap-12px">
<div class="text-13px text-[#64748b]">
<span>需求 {{ product.requirementCount }}</span>
<span class="mx-8px text-[#cbd5e1]">|</span>
<span>缺陷 {{ product.bugCount }}</span>
</div>
<ElButton type="primary" :loading="entering" @click="handleEnter">进入产品</ElButton>
</div>
</ElCard>
</template>
<style scoped>
.product-entry-card {
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top right, rgb(16 185 129 / 7%), transparent 28%),
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
}
.product-entry-card__code {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background-color: rgb(15 23 42 / 92%);
color: #fff;
font-size: 11px;
letter-spacing: 0.08em;
}
.product-entry-card__pulse {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
background: radial-gradient(circle, rgb(14 165 233 / 82%), rgb(14 165 233 / 16%));
box-shadow: 0 0 0 6px rgb(14 165 233 / 8%);
}
.product-entry-card__fact {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 14px;
background-color: rgb(241 245 249 / 88%);
}
.product-entry-card__fact-label {
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.product-entry-card__fact-value {
color: rgb(15 23 42 / 90%);
font-size: 15px;
}
.product-entry-card__focus-chip {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
border: 1px dashed rgb(125 211 252 / 80%);
border-radius: 999px;
color: rgb(14 116 144 / 92%);
font-size: 12px;
background-color: rgb(236 254 255 / 88%);
}
</style>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProductOperateDialog' });
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
rowData?: Api.Product.Product | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', productId?: string): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
interface Model {
code: string;
directionCode: string;
name: string;
managerUserId: string | null;
description: string;
}
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const isEditMode = computed(() => Boolean(props.rowData?.id));
const dialogTitle = computed(() => (isEditMode.value ? '编辑产品' : '新增产品'));
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
return '';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
const rules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')],
managerUserId: [createRequiredRule('请选择产品经理')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
code: '',
directionCode: '',
name: '',
managerUserId: null,
description: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
return;
}
const payload: Api.Product.SaveProductParams = {
code: getNullableText(model.value.code),
directionCode: model.value.directionCode,
name: model.value.name.trim(),
// Long ID 必须以 string 提交,禁止再转成 number。
managerUserId,
description: getNullableText(model.value.description)
};
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const result = await fetchUpdateProduct({
id: props.rowData.id,
...payload
});
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('产品编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
return;
}
const result = await fetchCreateProduct(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('产品新增成功');
closeDialog();
emit('submitted', result.data);
}
watch(visible, async value => {
if (!value) {
return;
}
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
await nextTick();
formRef.value?.clearValidate();
return;
}
loading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
loading.value = false;
if (error || !data) {
return;
}
model.value = {
code: data.code || '',
directionCode: data.directionCode || '',
name: data.name || '',
managerUserId: data.managerUserId || null,
description: data.description || ''
};
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
<ElInput
:model-value="model.code"
readonly
class="product-operate-dialog__readonly-input"
placeholder="未获取到产品编码"
/>
</ElFormItem>
<ElFormItem v-else label="产品编码" prop="code">
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择产品方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode">
<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="managerDisplayName"
readonly
class="product-operate-dialog__readonly-input"
placeholder="未配置产品经理"
/>
</ElFormItem>
<ElFormItem v-else label="产品经理" prop="managerUserId">
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="请选择产品经理">
<ElOption v-for="item in managerUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品描述" prop="description">
<ElInput
v-model="model.description"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入产品描述"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.product-operate-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(.product-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.product-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.product-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
defineOptions({ name: 'ProductSearch' });
interface Props {
managerOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="关键词">
<ElInput v-model="model.keyword" clearable placeholder="产品名称 / 编号" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="产品经理">
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="筛选产品经理">
<ElOption v-for="item in managerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="产品方向">
<DictSelect
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="筛选产品方向"
/>
</ElFormItem>
</ElCol>
</TableSearchPanel>
</template>
<style scoped></style>