feat(projects): 微调
This commit is contained in:
@@ -2,6 +2,8 @@ import { computed, defineComponent, h, ref } from 'vue';
|
||||
import type { Component, PropType } from 'vue';
|
||||
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
import IconMdiDotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||
import IconMdiChevronDown from '~icons/mdi/chevron-down';
|
||||
|
||||
export type BusinessTableAction = {
|
||||
key: string;
|
||||
@@ -162,11 +164,11 @@ export default defineComponent({
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
{props.variant === 'icon' ? (
|
||||
<icon-mdi-dots-horizontal class="business-table-action-icon" />
|
||||
<IconMdiDotsHorizontal class="business-table-action-icon" />
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
<IconMdiChevronDown class="text-14px" />
|
||||
</span>
|
||||
)}
|
||||
</ElButton>
|
||||
|
||||
84
src/components/custom/current-user-role-tags.vue
Normal file
84
src/components/custom/current-user-role-tags.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
defineOptions({ name: 'CurrentUserRoleTags' });
|
||||
|
||||
interface Props {
|
||||
/** 当前登录用户在该对象(产品/项目)的角色;无角色为 []。后端只读计算字段,随登录身份变化 */
|
||||
roles?: Api.Common.CurrentUserRole[] | null;
|
||||
/** 无业务角色时的占位文案 */
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { roles: () => [], emptyText: '--' });
|
||||
|
||||
/**
|
||||
* 角色族按 roleKey 后缀匹配。
|
||||
*
|
||||
* 后端 roleKey 为域前缀风格:product_manager / project_manager / product_creator /
|
||||
* project_creator / *_observer 等(见 src/constants/business.ts 的经理 code);
|
||||
* 文档示例里的裸 creator / implicit_observer 不是真实 key,故不能按字面量精确匹配。
|
||||
*/
|
||||
function isManagerRole(roleKey: string) {
|
||||
return /manager$/i.test(roleKey);
|
||||
}
|
||||
|
||||
function isCreatorRole(roleKey: string) {
|
||||
return /creator$/i.test(roleKey);
|
||||
}
|
||||
|
||||
/** 系统隐式角色:创建者 / 隐式观察者,弱化为淡灰标签 */
|
||||
function isMuted(roleKey: string) {
|
||||
return isCreatorRole(roleKey) || /observer$/i.test(roleKey) || roleKey.includes('implicit');
|
||||
}
|
||||
|
||||
const items = computed(() => {
|
||||
const roles = props.roles ?? [];
|
||||
// 当前用户是产品经理 / 项目经理时,隐藏创建者标签(隐式观察者不受影响)
|
||||
const hasManager = roles.some(role => isManagerRole(role.roleKey));
|
||||
const visibleRoles = hasManager ? roles.filter(role => !isCreatorRole(role.roleKey)) : roles;
|
||||
|
||||
return visibleRoles.map((role, index) => ({
|
||||
key: `${role.roleKey}-${index}`,
|
||||
roleName: role.roleName,
|
||||
muted: isMuted(role.roleKey)
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="items.length" class="current-user-role-tags">
|
||||
<ElTag
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
size="small"
|
||||
effect="plain"
|
||||
round
|
||||
:type="item.muted ? 'info' : 'primary'"
|
||||
:class="{ 'current-user-role-tags__muted': item.muted }"
|
||||
>
|
||||
{{ item.roleName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<span v-else class="current-user-role-tags__empty">{{ emptyText }}</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.current-user-role-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
// 列设置 align="center" 只影响文本流;flex 容器需显式居中标签
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// 隐式角色(创建者 / 隐式观察者)弱化:在灰色 info 标签基础上再降透明度
|
||||
.current-user-role-tags__muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.current-user-role-tags__empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -191,6 +191,6 @@ export async function fetchGetDictDataByCode(code: string) {
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeDictData)
|
||||
list: (data.list ?? []).map(normalizeDictData)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import { normalizeProductMember, normalizeProductSettings } from './product-shar
|
||||
|
||||
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
|
||||
|
||||
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & {
|
||||
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId' | 'currentUserRoles'> & {
|
||||
id: string | number;
|
||||
managerUserId?: string | number | null;
|
||||
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
|
||||
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
|
||||
};
|
||||
|
||||
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
|
||||
@@ -39,7 +41,8 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
||||
return {
|
||||
...product,
|
||||
id: normalizeStringId(product.id),
|
||||
managerUserId: normalizeNullableStringId(product.managerUserId) ?? ''
|
||||
managerUserId: normalizeNullableStringId(product.managerUserId) ?? '',
|
||||
currentUserRoles: product.currentUserRoles ?? []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,14 @@ const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
||||
|
||||
export type ProjectResponse = Omit<
|
||||
Api.Project.Project,
|
||||
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
|
||||
| 'id'
|
||||
| 'managerUserId'
|
||||
| 'productId'
|
||||
| 'plannedStartDate'
|
||||
| 'plannedEndDate'
|
||||
| 'actualStartDate'
|
||||
| 'actualEndDate'
|
||||
| 'currentUserRoles'
|
||||
> & {
|
||||
id: string | number;
|
||||
managerUserId?: string | number | null;
|
||||
@@ -56,6 +63,8 @@ export type ProjectResponse = Omit<
|
||||
plannedEndDate?: ProjectLocalDateValue;
|
||||
actualStartDate?: ProjectLocalDateValue;
|
||||
actualEndDate?: ProjectLocalDateValue;
|
||||
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
|
||||
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
|
||||
};
|
||||
|
||||
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
|
||||
@@ -96,7 +105,8 @@ export function normalizeProject(project: ProjectResponse): Api.Project.Project
|
||||
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
|
||||
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
|
||||
actualStartDate: normalizeProjectLocalDate(project.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(project.actualEndDate)
|
||||
actualEndDate: normalizeProjectLocalDate(project.actualEndDate),
|
||||
currentUserRoles: project.currentUserRoles ?? []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
window.$notification?.success({
|
||||
title: $t('page.login.common.loginSuccess'),
|
||||
message: $t('page.login.common.welcomeBack', { userName: userInfo.userName }),
|
||||
message: $t('page.login.common.welcomeBack', { userName: userInfo.nickname || userInfo.userName }),
|
||||
duration: 4500
|
||||
});
|
||||
}
|
||||
|
||||
16
src/typings/api/common.d.ts
vendored
16
src/typings/api/common.d.ts
vendored
@@ -31,6 +31,22 @@ declare namespace Api {
|
||||
*/
|
||||
type EnableStatus = '1' | '2';
|
||||
|
||||
/**
|
||||
* 列表项「当前登录用户在该对象的角色」(产品 / 项目列表共用)。
|
||||
*
|
||||
* 后端只读计算字段,随登录身份变化:同一份列表不同账号看到的内容不同;无角色为 []。
|
||||
* 提交 / 更新接口不需要回传它。
|
||||
*/
|
||||
interface CurrentUserRole {
|
||||
/**
|
||||
* 角色稳定标识(程序判断用,不随中文名变化)。
|
||||
* 例:product_manager / project_manager / developer / tester / watcher / creator / implicit_observer。
|
||||
*/
|
||||
roleKey: string;
|
||||
/** 角色中文名(直接展示) */
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
/** common record */
|
||||
type CommonRecord<T = any> = {
|
||||
/** record id */
|
||||
|
||||
2
src/typings/api/product.d.ts
vendored
2
src/typings/api/product.d.ts
vendored
@@ -67,6 +67,8 @@ declare namespace Api {
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 当前登录用户在该产品的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||
}
|
||||
|
||||
interface ProductSettingBaseInfo {
|
||||
|
||||
2
src/typings/api/project.d.ts
vendored
2
src/typings/api/project.d.ts
vendored
@@ -851,6 +851,8 @@ declare namespace Api {
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 当前登录用户在该项目的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||
}
|
||||
|
||||
interface ProjectContext {
|
||||
|
||||
6
src/typings/components.d.ts
vendored
6
src/typings/components.d.ts
vendored
@@ -23,6 +23,7 @@ declare module 'vue' {
|
||||
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
||||
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||
CurrentUserRoleTags: typeof import('./../components/custom/current-user-role-tags.vue')['default']
|
||||
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
|
||||
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
||||
DictSelect: typeof import('./../components/custom/dict-select.vue')['default']
|
||||
@@ -125,9 +126,13 @@ declare module 'vue' {
|
||||
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
|
||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
|
||||
IconIcRoundFolder: typeof import('~icons/ic/round-folder')['default']
|
||||
IconIcRoundInventory2: typeof import('~icons/ic/round-inventory2')['default']
|
||||
IconIcRoundPackage2: typeof import('~icons/ic/round-package2')['default']
|
||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
|
||||
IconIcRoundRocketLaunch: typeof import('~icons/ic/round-rocket-launch')['default']
|
||||
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
|
||||
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
|
||||
@@ -137,6 +142,7 @@ declare module 'vue' {
|
||||
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default']
|
||||
IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default']
|
||||
IconMaterialSymbolsPackage2: typeof import('~icons/material-symbols/package2')['default']
|
||||
'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default']
|
||||
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
|
||||
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
||||
|
||||
@@ -148,29 +148,29 @@ const waves = [
|
||||
|
||||
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
|
||||
const towers = [
|
||||
{ x: 150, s: 1 },
|
||||
{ x: 540, s: 0.85 },
|
||||
{ x: 1280, s: 0.7 }
|
||||
{ x: 150, s: 1.42 },
|
||||
{ x: 540, s: 1.2 },
|
||||
{ x: 1280, s: 1.0 }
|
||||
];
|
||||
|
||||
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点 */
|
||||
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点(随铁塔 scale 同步重算) */
|
||||
const powerLines = [
|
||||
{ path: 'M -60,762 Q 30,800 92,750' },
|
||||
{ path: 'M 208,750 Q 350,819 491,768' },
|
||||
{ path: 'M 589,768 Q 914,867 1239,786' },
|
||||
{ path: 'M 1321,786 Q 1430,824 1520,804' }
|
||||
{ path: 'M -60,712 Q 4,756 68,700' },
|
||||
{ path: 'M 232,700 Q 350,777 470,726' },
|
||||
{ path: 'M 610,726 Q 915,831 1222,750' },
|
||||
{ path: 'M 1338,750 Q 1429,788 1520,768' }
|
||||
];
|
||||
|
||||
/** 导线上滑过的电流光点 */
|
||||
/** 导线上滑过的电流光点(端点对齐 powerLines 的中间两段) */
|
||||
const lineSparks = [
|
||||
{ key: 'spark-1', path: 'M 208,750 Q 350,819 491,768', dur: '5s', begin: '0s' },
|
||||
{ key: 'spark-2', path: 'M 589,768 Q 914,867 1239,786', dur: '7s', begin: '2s' }
|
||||
{ key: 'spark-1', path: 'M 232,700 Q 350,777 470,726', dur: '5s', begin: '0s' },
|
||||
{ key: 'spark-2', path: 'M 610,726 Q 915,831 1222,750', dur: '7s', begin: '2s' }
|
||||
];
|
||||
|
||||
/** 风机(新能源应用场景),dur 为叶轮旋转周期 */
|
||||
const turbines = [
|
||||
{ key: 'turbine-1', x: 715, s: 0.9, dur: '9s' },
|
||||
{ key: 'turbine-2', x: 828, s: 0.6, dur: '13s' }
|
||||
{ key: 'turbine-1', x: 715, s: 1.28, dur: '9s' },
|
||||
{ key: 'turbine-2', x: 828, s: 0.88, dur: '13s' }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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)
|
||||
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;
|
||||
}
|
||||
],
|
||||
immediate: 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"
|
||||
<div class="flex flex-none items-center gap-8px">
|
||||
<ElButton
|
||||
plain
|
||||
:disabled="!showDirectionLayer || !allProducts.length"
|
||||
@click="allCollapsed = !allCollapsed"
|
||||
>
|
||||
<template #default>
|
||||
<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>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</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>
|
||||
@@ -2,9 +2,10 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElButton, ElEmpty, ElProgress, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { ArrowDown, Collection, QuestionFilled } from '@element-plus/icons-vue';
|
||||
import { ArrowDown, QuestionFilled } from '@element-plus/icons-vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import CurrentUserRoleTags from '@/components/custom/current-user-role-tags.vue';
|
||||
import { getProjectStatusLabel, getProjectStatusTagType } from '../../shared/project-master-data';
|
||||
|
||||
defineOptions({ name: 'ProjectGroupedTable' });
|
||||
@@ -202,7 +203,8 @@ interface FlatRow {
|
||||
project?: Api.Project.Project;
|
||||
}
|
||||
|
||||
const COLUMN_COUNT = 7;
|
||||
// 项目行列数(名称/类型/经理/我的角色/进度/计划周期/状态/更新),非项目行整行合并用
|
||||
const COLUMN_COUNT = 8;
|
||||
|
||||
/** 单个产品组铺平为行:产品行 +(未折叠时)占位/项目/收纳行 */
|
||||
function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
|
||||
@@ -218,7 +220,12 @@ function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
|
||||
}
|
||||
|
||||
for (const project of visibleProjects(group)) {
|
||||
rows.push({ rowType: 'project', key: `proj-${project.id}`, group, project });
|
||||
rows.push({
|
||||
rowType: 'project',
|
||||
key: `proj-${project.id}`,
|
||||
group,
|
||||
project
|
||||
});
|
||||
}
|
||||
|
||||
if (hiddenCount(group) > 0) {
|
||||
@@ -396,10 +403,8 @@ function overdueDays(project: Api.Project.Project) {
|
||||
<div v-else-if="row.rowType === 'product'" class="pg-prod-line">
|
||||
<ElIcon class="pg-toggle"><ArrowDown /></ElIcon>
|
||||
<span class="pg-prod-icon" :class="{ 'pg-prod-icon--orphan': row.group.orphan }">
|
||||
<ElIcon>
|
||||
<QuestionFilled v-if="row.group.orphan" />
|
||||
<Collection v-else />
|
||||
</ElIcon>
|
||||
<ElIcon v-if="row.group.orphan"><QuestionFilled /></ElIcon>
|
||||
<icon-material-symbols-package-2 v-else />
|
||||
</span>
|
||||
<span class="pg-prod-name" :class="{ 'pg-prod-name--orphan': row.group.orphan }">
|
||||
{{ row.group.productName }}
|
||||
@@ -429,11 +434,16 @@ function overdueDays(project: Api.Project.Project) {
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.rowType === 'project'" class="pg-proj-name">
|
||||
<span class="pg-proj-icon">
|
||||
<icon-ic-round-folder />
|
||||
</span>
|
||||
<div class="pg-proj-main">
|
||||
<ElButton link type="primary" class="pg-proj-link" @click="emit('enter', row.project)">
|
||||
{{ row.project.projectName }}
|
||||
</ElButton>
|
||||
<div class="pg-sub-code">{{ row.project.projectCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pg-more-line">
|
||||
<template v-if="expandingKeys.has(groupKey(row.group))">
|
||||
@@ -454,18 +464,24 @@ function overdueDays(project: Api.Project.Project) {
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="项目类型" width="110" align="left" show-overflow-tooltip>
|
||||
<ElTableColumn label="项目类型" width="110" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rowType === 'project'">{{ getTypeLabel(row.project.projectType, '--') }}</template>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="项目经理" width="100" align="left" show-overflow-tooltip>
|
||||
<ElTableColumn label="项目经理" width="100" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rowType === 'project'">{{ projectManagerLabel(row.project) }}</template>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="我的身份" min-width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<CurrentUserRoleTags v-if="row.rowType === 'project'" :roles="row.project.currentUserRoles" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="进度" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.rowType === 'project'" class="pg-progress">
|
||||
@@ -660,13 +676,44 @@ function overdueDays(project: Api.Project.Project) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// === 提示行 / 项目行 / 收纳行:统一缩进到产品名起始位 ===
|
||||
// === 提示行 / 收纳行:统一缩进到产品名起始位 ===
|
||||
.pg-hint,
|
||||
.pg-more-line,
|
||||
.pg-proj-name {
|
||||
.pg-more-line {
|
||||
margin-left: 55px;
|
||||
}
|
||||
|
||||
// 项目行:文件夹图标 + 文本块;图标占缩进位(margin 25 + icon22 + gap8 = 55),项目名仍与产品名对齐
|
||||
.pg-proj-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 25px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// 项目文件夹图标:浅蓝底 + 同色系深字,与产品库存箱图标同一视觉语言(父=箱子 / 子=文件夹)
|
||||
.pg-proj-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: rgb(240 249 255 / 96%);
|
||||
color: rgb(2 132 199 / 92%);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pg-proj-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// 贴左:避免 ElButton(link) 被 stretch 拉满后文字居中
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pg-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ViewContext {
|
||||
}
|
||||
|
||||
export function useTaskViewContext() {
|
||||
const state = reactive<ViewContext>({ type: 'my' });
|
||||
const state = reactive<ViewContext>({ type: 'all' });
|
||||
|
||||
function switchToMine() {
|
||||
state.type = 'my';
|
||||
|
||||
@@ -64,7 +64,7 @@ const { context: viewContext, switchToMine, switchToAll } = useTaskViewContext()
|
||||
|
||||
// 执行域独立视角:跟任务域 viewContext 完全独立,切换互不影响。
|
||||
// 用 inline reactive 即可,不抽 composable(只在本页用,YAGNI)。
|
||||
const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'my' });
|
||||
const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'all' });
|
||||
|
||||
function switchExecutionToMine() {
|
||||
executionViewContext.type = 'my';
|
||||
@@ -406,8 +406,8 @@ async function handleExecutionStatusFilter(status: ExecutionStatusFilter) {
|
||||
selectedStatus.value = status;
|
||||
// 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution
|
||||
if (selectedExecution.value) selectedExecution.value = null;
|
||||
// 左侧范围切换默认回「我参与的」视角,符合"在该范围内看自己的"主用法
|
||||
switchToMine();
|
||||
// 左侧范围切换默认回「所有任务」视角,符合"在该范围内看全部"的主用法
|
||||
switchToAll();
|
||||
await reloadExecutionData(1);
|
||||
}
|
||||
|
||||
@@ -437,9 +437,9 @@ async function getExecutionDetail(row: Api.Project.ProjectExecution) {
|
||||
}
|
||||
|
||||
function handleSelectExecution(row: Api.Project.ProjectExecution) {
|
||||
// 锚定具体执行,默认回「我参与的」视角
|
||||
// 锚定具体执行,默认回「所有任务」视角
|
||||
selectedExecution.value = row;
|
||||
switchToMine();
|
||||
switchToAll();
|
||||
}
|
||||
|
||||
function handleSelectPerspective(type: 'my' | 'all') {
|
||||
|
||||
@@ -377,11 +377,11 @@ async function loadFormOptions() {
|
||||
}
|
||||
|
||||
if (!companyResult.error) {
|
||||
companyOptions.value = companyResult.data;
|
||||
companyOptions.value = companyResult.data.list;
|
||||
}
|
||||
|
||||
if (!orgCodeResult.error) {
|
||||
orgCodeOptions.value = orgCodeResult.data;
|
||||
orgCodeOptions.value = orgCodeResult.data.list;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -359,7 +359,9 @@ watch(activeTab, async tab => {
|
||||
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
|
||||
<span>每日工时</span>
|
||||
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
|
||||
<span class="ww-section-info-wrap">
|
||||
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,11 +537,16 @@ watch(activeTab, async tab => {
|
||||
color: var(--el-text-color-placeholder);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ww-section-info-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.ww-section-info {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
flex-shrink: 0;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.ww-pie-wrap {
|
||||
|
||||
Reference in New Issue
Block a user