feat(projects): 微调
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
<script setup lang="tsx">
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { Box, DeleteFilled, Document, Menu, VideoPause, VideoPlay } 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 { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
|
||||
import ProductGroupedTable from './modules/product-grouped-table.vue';
|
||||
import ProductOperateDialog from './modules/product-operate-dialog.vue';
|
||||
import ProductSearch from './modules/product-search.vue';
|
||||
|
||||
@@ -23,14 +20,12 @@ interface StatusVisualMeta {
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
|
||||
|
||||
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
||||
|
||||
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
pageSize: -1,
|
||||
keyword: '',
|
||||
directionCode: undefined,
|
||||
managerUserId: undefined,
|
||||
@@ -39,36 +34,10 @@ function getInitSearchParams(): Api.Product.ProductSearchParams {
|
||||
};
|
||||
}
|
||||
|
||||
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 formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/** 状态视觉资产(icon/tone)是前端本地映射;状态名直接渲染后端 statusName,不做本地名称映射 */
|
||||
const STATUS_VISUALS: Record<string, StatusVisualMeta> = {
|
||||
active: { tone: 'teal', icon: VideoPlay },
|
||||
@@ -89,7 +58,11 @@ const operateVisible = ref(false);
|
||||
const editingRow = ref<Api.Product.Product | null>(null);
|
||||
const { routerPush } = useRouterPush();
|
||||
|
||||
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
/** 当前筛选口径下的全量产品(pageSize=-1 一次拉全),分组 / 收纳 / 假分页全在 ProductGroupedTable 内 */
|
||||
const allProducts = ref<Api.Product.Product[]>([]);
|
||||
const loading = ref(false);
|
||||
/** 折叠全部开关(true = 全部方向折叠);仅多方向视图有意义 */
|
||||
const allCollapsed = ref(false);
|
||||
|
||||
/** 状态看板项(overview-summary items,状态机动态下发,已按 sort 升序) */
|
||||
const statusBoardItems = ref<Api.Product.OverviewStatusItem[]>([]);
|
||||
@@ -100,6 +73,10 @@ const managerLabelMap = computed(() => {
|
||||
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
/** 当前筛选口径下可见产品跨方向数:≥2 时渲染方向分层,否则单方向平铺 */
|
||||
const directionCount = computed(() => new Set(allProducts.value.map(item => item.directionCode || '')).size);
|
||||
const showDirectionLayer = computed(() => directionCount.value >= 2);
|
||||
|
||||
const statusItems = computed(() => [
|
||||
{ key: 'all', label: '全部产品', count: statusBoardTotal.value, tone: 'sky' as StatusNavTone, icon: Menu },
|
||||
...statusBoardItems.value.map(item => {
|
||||
@@ -115,92 +92,29 @@ const statusItems = computed(() => [
|
||||
})
|
||||
]);
|
||||
|
||||
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,
|
||||
pageNo: 1,
|
||||
pageSize: -1,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
statusCode: selectedStatus.value === 'all' ? undefined : 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 => formatDate(row.updateTime)
|
||||
}
|
||||
],
|
||||
immediate: false
|
||||
});
|
||||
async function loadProducts() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { error, data } = await fetchGetProductPage(createRequestParams());
|
||||
allProducts.value = !error && data ? data.list : [];
|
||||
} catch {
|
||||
allProducts.value = [];
|
||||
window.$message?.error('产品列表加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManagerOptions() {
|
||||
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||
@@ -223,31 +137,23 @@ async function loadOverviewData() {
|
||||
statusBoardTotal.value = error || !overviewSummary ? 0 : overviewSummary.total;
|
||||
}
|
||||
|
||||
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 refreshPageData() {
|
||||
await Promise.all([loadManagerOptions(), loadOverviewData(), loadProducts()]);
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await reloadProductTable(1);
|
||||
await loadProducts();
|
||||
}
|
||||
|
||||
async function handleResetSearch() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize
|
||||
});
|
||||
|
||||
await reloadProductTable(1);
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
await loadProducts();
|
||||
}
|
||||
|
||||
async function handleStatusChange(status: string) {
|
||||
selectedStatus.value = status;
|
||||
await Promise.all([loadOverviewData(), reloadProductTable(1)]);
|
||||
allCollapsed.value = false;
|
||||
await Promise.all([loadOverviewData(), loadProducts()]);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@@ -267,7 +173,7 @@ async function enterProductContext(row: Api.Product.Product) {
|
||||
async function handleProductSubmitted(productId?: string) {
|
||||
const isEditing = Boolean(productId && editingRow.value?.id === productId);
|
||||
|
||||
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
|
||||
await refreshPageData();
|
||||
|
||||
if (isEditing) {
|
||||
editingRow.value = null;
|
||||
@@ -319,7 +225,7 @@ onMounted(async () => {
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="product-table-card-body">
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="product-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -331,43 +237,45 @@ onMounted(async () => {
|
||||
getProductStatusLabel(selectedStatus)
|
||||
}}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
<ElTag effect="plain">{{ allProducts.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 class="flex flex-none items-center gap-8px">
|
||||
<ElButton
|
||||
plain
|
||||
:disabled="!showDirectionLayer || !allProducts.length"
|
||||
@click="allCollapsed = !allCollapsed"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-unfold-more v-if="allCollapsed" class="text-icon" />
|
||||
<icon-ic-round-unfold-less v-else class="text-icon" />
|
||||
</template>
|
||||
{{ allCollapsed ? '展开全部' : '折叠全部' }}
|
||||
</ElButton>
|
||||
<ElButton plain :loading="loading" @click="refreshPageData">
|
||||
<template #icon>
|
||||
<icon-ic-round-refresh class="text-icon" />
|
||||
</template>
|
||||
刷新
|
||||
</ElButton>
|
||||
<ElButton plain type="primary" @click="openCreate">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
</div>
|
||||
</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']"
|
||||
<ProductGroupedTable
|
||||
:products="allProducts"
|
||||
:loading="loading"
|
||||
:show-direction-layer="showDirectionLayer"
|
||||
:all-collapsed="allCollapsed"
|
||||
:manager-label-map="managerLabelMap"
|
||||
@enter="enterProductContext"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
@@ -483,12 +391,6 @@ onMounted(async () => {
|
||||
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;
|
||||
@@ -496,10 +398,6 @@ onMounted(async () => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-name-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-card-header,
|
||||
.product-status-item__top {
|
||||
|
||||
580
src/views/product/list/modules/product-grouped-table.vue
Normal file
580
src/views/product/list/modules/product-grouped-table.vue
Normal file
@@ -0,0 +1,580 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElButton, ElEmpty, ElIcon, ElPagination, ElTable, ElTableColumn, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { ArrowDown } from '@element-plus/icons-vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import CurrentUserRoleTags from '@/components/custom/current-user-role-tags.vue';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
|
||||
|
||||
defineOptions({ name: 'ProductGroupedTable' });
|
||||
|
||||
interface Props {
|
||||
/** 当前筛选口径下的全量产品(前端分组 / 收纳 / 假分页都基于它) */
|
||||
products: Api.Product.Product[];
|
||||
loading?: boolean;
|
||||
/** 是否渲染方向小节层(可见方向数 ≥2);false = 单方向平铺 */
|
||||
showDirectionLayer: boolean;
|
||||
/** 折叠全部开关(true = 全部方向折叠,仅保留方向头) */
|
||||
allCollapsed: boolean;
|
||||
/** userId -> 昵称,产品经理列兜底回显 */
|
||||
managerLabelMap: Map<string, string>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { loading: false });
|
||||
|
||||
interface Emits {
|
||||
(e: 'enter', product: Api.Product.Product): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { getLabel: getDirectionLabel, dictOptions: directionOptions } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
|
||||
/** 方向内默认直出条数(超出收纳为「还有 X 个」) */
|
||||
const DEFAULT_TOP_N = 5;
|
||||
/** 展开后方向内每页条数(超出 1 页才出分页器) */
|
||||
const PAGE_SIZE = 10;
|
||||
/** 产品行多列数(名称/编码/经理/我的角色/状态/原因/更新),非产品行整行合并用 */
|
||||
const COLUMN_COUNT = 7;
|
||||
/** 产品描述副行最大展示字符数,超出截断并追加 …(完整内容走 title 悬浮) */
|
||||
const PRODUCT_DESC_MAX_LEN = 48;
|
||||
|
||||
interface DirectionGroup {
|
||||
directionCode: string;
|
||||
products: Api.Product.Product[];
|
||||
}
|
||||
|
||||
/** updateTime 后端实际可能为时间戳(number)或字符串,统一转毫秒数比较;缺失/非法兜底 0 */
|
||||
function updateTimeValue(value: Api.Product.Product['updateTime']) {
|
||||
const time = dayjs(value ?? 0).valueOf();
|
||||
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
/** 按方向分组:方向顺序按字典 options,未知方向落最后;组内按最近更新倒序 */
|
||||
const groups = computed<DirectionGroup[]>(() => {
|
||||
const map = new Map<string, Api.Product.Product[]>();
|
||||
|
||||
for (const product of props.products) {
|
||||
const code = product.directionCode || '';
|
||||
const list = map.get(code);
|
||||
|
||||
if (list) {
|
||||
list.push(product);
|
||||
} else {
|
||||
map.set(code, [product]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const list of map.values()) {
|
||||
list.sort((left, right) => updateTimeValue(right.updateTime) - updateTimeValue(left.updateTime));
|
||||
}
|
||||
|
||||
const orderIndex = new Map(directionOptions.value.map((option, index): [string, number] => [option.value, index]));
|
||||
|
||||
return [...map.entries()]
|
||||
.map(([directionCode, list]) => ({ directionCode, products: list }))
|
||||
.sort((left, right) => {
|
||||
const leftOrder = orderIndex.has(left.directionCode)
|
||||
? orderIndex.get(left.directionCode)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const rightOrder = orderIndex.has(right.directionCode)
|
||||
? orderIndex.get(right.directionCode)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return leftOrder - rightOrder;
|
||||
});
|
||||
});
|
||||
|
||||
// === 方向折叠态(折叠 = 隐藏该方向下全部产品;与 top-N 收纳相互独立,对齐项目列表) ===
|
||||
/** 已折叠方向(按 directionCode) */
|
||||
const collapsedDirections = ref(new Set<string>());
|
||||
|
||||
function collapseAllDirections() {
|
||||
return new Set(groups.value.map(group => group.directionCode));
|
||||
}
|
||||
|
||||
function toggleDirection(code: string) {
|
||||
const next = new Set(collapsedDirections.value);
|
||||
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
|
||||
collapsedDirections.value = next;
|
||||
}
|
||||
|
||||
// === 收纳 / 方向内假分页态(数据刷新即重置) ===
|
||||
/** 已展开方向(多方向:从前 5 展开到分页态);单方向恒为展开 */
|
||||
const expandedDirections = ref(new Set<string>());
|
||||
/** 方向内当前页码:directionCode -> page(默认 1) */
|
||||
const directionPages = ref(new Map<string, number>());
|
||||
|
||||
watch(
|
||||
() => props.products,
|
||||
() => {
|
||||
expandedDirections.value = new Set();
|
||||
directionPages.value = new Map();
|
||||
collapsedDirections.value = props.allCollapsed ? collapseAllDirections() : new Set();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.allCollapsed,
|
||||
value => {
|
||||
collapsedDirections.value = value ? collapseAllDirections() : new Set();
|
||||
}
|
||||
);
|
||||
|
||||
/** 单方向直接进入展开(分页)态,无 5 档收纳 */
|
||||
function isExpanded(code: string) {
|
||||
return !props.showDirectionLayer || expandedDirections.value.has(code);
|
||||
}
|
||||
|
||||
function getPage(code: string) {
|
||||
return directionPages.value.get(code) ?? 1;
|
||||
}
|
||||
|
||||
/** 当前方向应渲染的产品:未展开取前 N;展开且 >1 页取当前页切片,否则全量 */
|
||||
function visibleProducts(group: DirectionGroup) {
|
||||
if (!isExpanded(group.directionCode)) {
|
||||
return group.products.slice(0, DEFAULT_TOP_N);
|
||||
}
|
||||
|
||||
if (group.products.length > PAGE_SIZE) {
|
||||
const start = (getPage(group.directionCode) - 1) * PAGE_SIZE;
|
||||
return group.products.slice(start, start + PAGE_SIZE);
|
||||
}
|
||||
|
||||
return group.products;
|
||||
}
|
||||
|
||||
function toggleExpand(code: string) {
|
||||
const next = new Set(expandedDirections.value);
|
||||
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
const pages = new Map(directionPages.value);
|
||||
pages.delete(code);
|
||||
directionPages.value = pages;
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
|
||||
expandedDirections.value = next;
|
||||
}
|
||||
|
||||
function changePage(code: string, page: number) {
|
||||
const pages = new Map(directionPages.value);
|
||||
pages.set(code, page);
|
||||
directionPages.value = pages;
|
||||
}
|
||||
|
||||
// === 扁平行模型:分组结构铺平后交给 ElTable,非产品行整行合并单元格 ===
|
||||
type FlatRowType = 'dir' | 'product' | 'more' | 'pager';
|
||||
|
||||
interface FlatRow {
|
||||
rowType: FlatRowType;
|
||||
key: string;
|
||||
group?: DirectionGroup;
|
||||
product?: Api.Product.Product;
|
||||
}
|
||||
|
||||
const flatRows = computed<FlatRow[]>(() => {
|
||||
const rows: FlatRow[] = [];
|
||||
|
||||
for (const group of groups.value) {
|
||||
const code = group.directionCode || 'unknown';
|
||||
const total = group.products.length;
|
||||
|
||||
// 方向折叠:仅保留方向头,隐藏其下产品 / 收纳 / 分页行
|
||||
const collapsed = props.showDirectionLayer && collapsedDirections.value.has(group.directionCode);
|
||||
|
||||
if (props.showDirectionLayer) {
|
||||
rows.push({ rowType: 'dir', key: `dir-${code}`, group });
|
||||
}
|
||||
|
||||
if (!collapsed) {
|
||||
for (const product of visibleProducts(group)) {
|
||||
rows.push({ rowType: 'product', key: `prod-${product.id}`, group, product });
|
||||
}
|
||||
|
||||
if (isExpanded(group.directionCode) && total > PAGE_SIZE) {
|
||||
rows.push({ rowType: 'pager', key: `pager-${code}`, group });
|
||||
}
|
||||
|
||||
// 收起/展开切换行仅多方向出现(单方向无 5 档收纳)
|
||||
if (props.showDirectionLayer && total > DEFAULT_TOP_N) {
|
||||
rows.push({ rowType: 'more', key: `more-${code}`, group });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
function getRowKey(row: FlatRow) {
|
||||
return row.key;
|
||||
}
|
||||
|
||||
function spanMethod({ row, columnIndex }: { row: FlatRow; columnIndex: number }) {
|
||||
if (row.rowType === 'product') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return columnIndex === 0 ? { rowspan: 1, colspan: COLUMN_COUNT } : { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
|
||||
function rowClassName({ row }: { row: FlatRow }) {
|
||||
if (row.rowType === 'dir') {
|
||||
const collapsed = row.group ? collapsedDirections.value.has(row.group.directionCode) : false;
|
||||
return `pg-dir-row${collapsed ? ' is-collapsed' : ''}`;
|
||||
}
|
||||
|
||||
if (row.rowType === 'more') {
|
||||
return 'pg-more-row';
|
||||
}
|
||||
|
||||
if (row.rowType === 'pager') {
|
||||
return 'pg-pager-row';
|
||||
}
|
||||
|
||||
return 'pg-prod-row';
|
||||
}
|
||||
|
||||
function handleRowClick(row: FlatRow) {
|
||||
if (row.rowType === 'dir' && row.group) {
|
||||
toggleDirection(row.group.directionCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.rowType === 'more' && row.group) {
|
||||
toggleExpand(row.group.directionCode);
|
||||
}
|
||||
}
|
||||
|
||||
// === 回显 ===
|
||||
function getManagerName(managerUserId?: string | null) {
|
||||
if (!managerUserId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return props.managerLabelMap.get(String(managerUserId)) || String(managerUserId);
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/** 产品描述副行:控长截断,超出追加 …;完整内容由 title 悬浮展示 */
|
||||
function truncateDesc(text?: string | null) {
|
||||
const trimmed = (text ?? '').trim();
|
||||
|
||||
return trimmed.length > PRODUCT_DESC_MAX_LEN ? `${trimmed.slice(0, PRODUCT_DESC_MAX_LEN)}…` : trimmed;
|
||||
}
|
||||
|
||||
/** 描述是否被截断(放不下);仅截断时才追加「详情」入口 */
|
||||
function isDescTruncated(text?: string | null) {
|
||||
return (text ?? '').trim().length > PRODUCT_DESC_MAX_LEN;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
class="product-grouped-table"
|
||||
height="100%"
|
||||
:data="flatRows"
|
||||
:row-key="getRowKey"
|
||||
:span-method="spanMethod"
|
||||
:row-class-name="rowClassName"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn label="产品名称" min-width="240" align="left">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.rowType === 'dir'" class="pg-dir-line">
|
||||
<ElIcon class="pg-toggle"><ArrowDown /></ElIcon>
|
||||
<span class="pg-dir-chip"></span>
|
||||
<span class="pg-dir-name">
|
||||
{{ getDirectionLabel(row.group.directionCode, row.group.directionCode || '--') }}
|
||||
</span>
|
||||
<span class="pg-dir-meta">{{ row.group.products.length }} 个产品</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.rowType === 'product'" class="pg-prod-name">
|
||||
<span class="pg-prod-icon">
|
||||
<icon-material-symbols-package-2 />
|
||||
</span>
|
||||
<div class="pg-prod-main">
|
||||
<ElButton link type="primary" class="pg-prod-link" @click="emit('enter', row.product)">
|
||||
{{ row.product.name }}
|
||||
</ElButton>
|
||||
<div
|
||||
v-if="row.product.description?.trim()"
|
||||
class="pg-prod-desc"
|
||||
:title="row.product.description"
|
||||
@click="emit('enter', row.product)"
|
||||
>
|
||||
<span class="pg-prod-desc__text">{{ truncateDesc(row.product.description) }}</span>
|
||||
<span v-if="isDescTruncated(row.product.description)" class="pg-prod-desc__more">详情</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.rowType === 'more'" class="pg-more-line">
|
||||
<span class="pg-more-link">
|
||||
<ElIcon class="pg-more-icon" :class="{ 'pg-more-icon--up': isExpanded(row.group.directionCode) }">
|
||||
<ArrowDown />
|
||||
</ElIcon>
|
||||
<template v-if="isExpanded(row.group.directionCode)">收起</template>
|
||||
<template v-else>还有 {{ row.group.products.length - DEFAULT_TOP_N }} 个,展开查看</template>
|
||||
</span>
|
||||
<span v-if="!isExpanded(row.group.directionCode)" class="pg-more-hint">
|
||||
默认显示前 {{ DEFAULT_TOP_N }} 个,按最近更新排序
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.rowType === 'pager'" class="pg-pager-line" @click.stop>
|
||||
<ElPagination
|
||||
small
|
||||
layout="total, prev, pager, next"
|
||||
:total="row.group.products.length"
|
||||
:page-size="PAGE_SIZE"
|
||||
:current-page="getPage(row.group.directionCode)"
|
||||
@current-change="page => changePage(row.group.directionCode, page)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="产品编码" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rowType === 'product'">{{ row.product.code || '--' }}</template>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="产品经理" width="120">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rowType === 'product'">{{ getManagerName(row.product.managerUserId) }}</template>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="我的身份" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<CurrentUserRoleTags v-if="row.rowType === 'product'" :roles="row.product.currentUserRoles" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="管理状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag v-if="row.rowType === 'product'" :type="getProductStatusTagType(row.product.statusCode)">
|
||||
{{ getProductStatusLabel(row.product.statusCode) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="状态原因" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rowType === 'product'">{{ row.product.lastStatusReason?.trim() || '--' }}</template>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="最近更新" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.rowType === 'product'" class="pg-muted">{{ formatDate(row.product.updateTime) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<template #empty>
|
||||
<ElEmpty description="当前筛选条件下暂无产品" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-grouped-table {
|
||||
--el-table-row-hover-bg-color: rgb(240 249 255 / 55%);
|
||||
|
||||
// 全局 .el-table .cell { padding: 0 } 把内边距清零了,这里恢复本表的呼吸感
|
||||
:deep(td.el-table__cell > .cell),
|
||||
:deep(th.el-table__cell > .cell) {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
:deep(td.el-table__cell) {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
// 方向节标题行:无底色、上方留白,像章节标题而不是数据行(可点击折叠)
|
||||
:deep(.pg-dir-row > td.el-table__cell) {
|
||||
padding: 16px 0 6px;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.pg-prod-row > td.el-table__cell) {
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
:deep(.pg-more-row > td.el-table__cell),
|
||||
:deep(.pg-pager-row > td.el-table__cell) {
|
||||
padding: 6px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.pg-more-row > td.el-table__cell) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// === 方向节标题 ===
|
||||
.pg-toggle {
|
||||
flex: none;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.is-collapsed .pg-toggle {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.pg-dir-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pg-dir-chip {
|
||||
flex: none;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: rgb(14 165 233 / 85%);
|
||||
}
|
||||
|
||||
.pg-dir-name {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pg-dir-meta {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// === 产品行 ===
|
||||
.pg-prod-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pg-prod-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
background: rgb(240 249 255 / 96%);
|
||||
color: rgb(2 132 199 / 92%);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pg-prod-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// 贴左:避免 ElButton(link) 被 stretch 拉满列宽后文字居中
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pg-prod-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// 描述副行:控长截断 + 末尾「详情」链接,整行可点(进入对象域)
|
||||
.pg-prod-desc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pg-prod-desc__text {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pg-prod-desc__more {
|
||||
flex: none;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pg-prod-desc:hover .pg-prod-desc__more {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pg-muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
// === 收起/展开行 ===
|
||||
.pg-more-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 34px;
|
||||
}
|
||||
|
||||
.pg-more-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pg-more-icon {
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pg-more-icon--up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.pg-more-hint {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// === 方向内分页行 ===
|
||||
.pg-pager-line {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-left: 34px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user