630 lines
17 KiB
Vue
630 lines
17 KiB
Vue
<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 { fetchGetProductOverviewSummary, 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_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');
|
|
}
|
|
|
|
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<string, number>>({
|
|
active: 0,
|
|
archived: 0,
|
|
paused: 0,
|
|
abandoned: 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] ?? 0
|
|
}))
|
|
);
|
|
|
|
const overviewMetrics = computed(() => [
|
|
{
|
|
label: '可见产品',
|
|
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
|
|
hint: '当前接口可查询到的产品总量'
|
|
},
|
|
{
|
|
label: '当前启用',
|
|
value: statusCounts.value.active ?? 0,
|
|
hint: '正在持续服务和维护的产品'
|
|
},
|
|
{
|
|
label: '产品方向',
|
|
value: directionOptions.value.length,
|
|
hint: '已加载的方向字典项数量'
|
|
},
|
|
{
|
|
label: '废弃产品',
|
|
value: statusCounts.value.abandoned ?? 0,
|
|
hint: '已明确停止建设的产品'
|
|
}
|
|
]);
|
|
|
|
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)
|
|
}
|
|
]}
|
|
/>
|
|
)
|
|
}
|
|
],
|
|
immediate: false
|
|
});
|
|
|
|
async function loadManagerOptions() {
|
|
const { error, data: userList } = await fetchGetUserSimpleList();
|
|
|
|
if (error || !userList) {
|
|
managerUserOptions.value = [];
|
|
managerFilterOptions.value = [];
|
|
return;
|
|
}
|
|
|
|
const userSimpleList = sortManagerOptions(userList);
|
|
managerUserOptions.value = userSimpleList;
|
|
managerFilterOptions.value = userSimpleList;
|
|
}
|
|
|
|
async function loadOverviewData() {
|
|
const { error, data: overviewSummary } = await fetchGetProductOverviewSummary();
|
|
|
|
if (error || !overviewSummary) {
|
|
statusCounts.value = {};
|
|
return;
|
|
}
|
|
|
|
statusCounts.value = overviewSummary.statusCounts || {};
|
|
}
|
|
|
|
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>
|