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 type { Component, PropType } from 'vue';
|
||||||
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
import IconMdiDotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||||
|
import IconMdiChevronDown from '~icons/mdi/chevron-down';
|
||||||
|
|
||||||
export type BusinessTableAction = {
|
export type BusinessTableAction = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -162,11 +164,11 @@ export default defineComponent({
|
|||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
{props.variant === 'icon' ? (
|
{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">
|
<span class="inline-flex items-center gap-4px">
|
||||||
{$t('common.more')}
|
{$t('common.more')}
|
||||||
<icon-mdi-chevron-down class="text-14px" />
|
<IconMdiChevronDown class="text-14px" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</ElButton>
|
</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 => ({
|
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
|
||||||
...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`;
|
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;
|
id: string | number;
|
||||||
managerUserId?: string | number | null;
|
managerUserId?: string | number | null;
|
||||||
|
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
|
||||||
|
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
|
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
|
||||||
@@ -39,7 +41,8 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
|||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
id: normalizeStringId(product.id),
|
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<
|
export type ProjectResponse = Omit<
|
||||||
Api.Project.Project,
|
Api.Project.Project,
|
||||||
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
|
| 'id'
|
||||||
|
| 'managerUserId'
|
||||||
|
| 'productId'
|
||||||
|
| 'plannedStartDate'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'actualStartDate'
|
||||||
|
| 'actualEndDate'
|
||||||
|
| 'currentUserRoles'
|
||||||
> & {
|
> & {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
managerUserId?: string | number | null;
|
managerUserId?: string | number | null;
|
||||||
@@ -56,6 +63,8 @@ export type ProjectResponse = Omit<
|
|||||||
plannedEndDate?: ProjectLocalDateValue;
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
actualStartDate?: ProjectLocalDateValue;
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
actualEndDate?: ProjectLocalDateValue;
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
|
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
|
||||||
|
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
|
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
|
||||||
@@ -96,7 +105,8 @@ export function normalizeProject(project: ProjectResponse): Api.Project.Project
|
|||||||
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
|
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
|
||||||
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
|
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
|
||||||
actualStartDate: normalizeProjectLocalDate(project.actualStartDate),
|
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({
|
window.$notification?.success({
|
||||||
title: $t('page.login.common.loginSuccess'),
|
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
|
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';
|
type EnableStatus = '1' | '2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表项「当前登录用户在该对象的角色」(产品 / 项目列表共用)。
|
||||||
|
*
|
||||||
|
* 后端只读计算字段,随登录身份变化:同一份列表不同账号看到的内容不同;无角色为 []。
|
||||||
|
* 提交 / 更新接口不需要回传它。
|
||||||
|
*/
|
||||||
|
interface CurrentUserRole {
|
||||||
|
/**
|
||||||
|
* 角色稳定标识(程序判断用,不随中文名变化)。
|
||||||
|
* 例:product_manager / project_manager / developer / tester / watcher / creator / implicit_observer。
|
||||||
|
*/
|
||||||
|
roleKey: string;
|
||||||
|
/** 角色中文名(直接展示) */
|
||||||
|
roleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** common record */
|
/** common record */
|
||||||
type CommonRecord<T = any> = {
|
type CommonRecord<T = any> = {
|
||||||
/** record id */
|
/** 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;
|
createTime: string;
|
||||||
/** 更新时间 */
|
/** 更新时间 */
|
||||||
updateTime: string;
|
updateTime: string;
|
||||||
|
/** 当前登录用户在该产品的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||||
|
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductSettingBaseInfo {
|
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;
|
createTime: string;
|
||||||
/** 更新时间 */
|
/** 更新时间 */
|
||||||
updateTime: string;
|
updateTime: string;
|
||||||
|
/** 当前登录用户在该项目的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||||
|
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectContext {
|
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']
|
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
||||||
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||||
CountTo: typeof import('./../components/custom/count-to.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']
|
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
|
||||||
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
||||||
DictSelect: typeof import('./../components/custom/dict-select.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']
|
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
|
||||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['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']
|
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||||
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['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']
|
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||||
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
|
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
|
||||||
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
|
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
|
||||||
@@ -137,6 +142,7 @@ declare module 'vue' {
|
|||||||
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||||
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['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']
|
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:paperclip': typeof import('~icons/mdi/paperclip')['default']
|
||||||
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
|
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
|
||||||
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
||||||
|
|||||||
@@ -148,29 +148,29 @@ const waves = [
|
|||||||
|
|
||||||
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
|
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
|
||||||
const towers = [
|
const towers = [
|
||||||
{ x: 150, s: 1 },
|
{ x: 150, s: 1.42 },
|
||||||
{ x: 540, s: 0.85 },
|
{ x: 540, s: 1.2 },
|
||||||
{ x: 1280, s: 0.7 }
|
{ x: 1280, s: 1.0 }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点 */
|
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点(随铁塔 scale 同步重算) */
|
||||||
const powerLines = [
|
const powerLines = [
|
||||||
{ path: 'M -60,762 Q 30,800 92,750' },
|
{ path: 'M -60,712 Q 4,756 68,700' },
|
||||||
{ path: 'M 208,750 Q 350,819 491,768' },
|
{ path: 'M 232,700 Q 350,777 470,726' },
|
||||||
{ path: 'M 589,768 Q 914,867 1239,786' },
|
{ path: 'M 610,726 Q 915,831 1222,750' },
|
||||||
{ path: 'M 1321,786 Q 1430,824 1520,804' }
|
{ path: 'M 1338,750 Q 1429,788 1520,768' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 导线上滑过的电流光点 */
|
/** 导线上滑过的电流光点(端点对齐 powerLines 的中间两段) */
|
||||||
const lineSparks = [
|
const lineSparks = [
|
||||||
{ key: 'spark-1', path: 'M 208,750 Q 350,819 491,768', dur: '5s', begin: '0s' },
|
{ key: 'spark-1', path: 'M 232,700 Q 350,777 470,726', dur: '5s', begin: '0s' },
|
||||||
{ key: 'spark-2', path: 'M 589,768 Q 914,867 1239,786', dur: '7s', begin: '2s' }
|
{ key: 'spark-2', path: 'M 610,726 Q 915,831 1222,750', dur: '7s', begin: '2s' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 风机(新能源应用场景),dur 为叶轮旋转周期 */
|
/** 风机(新能源应用场景),dur 为叶轮旋转周期 */
|
||||||
const turbines = [
|
const turbines = [
|
||||||
{ key: 'turbine-1', x: 715, s: 0.9, dur: '9s' },
|
{ key: 'turbine-1', x: 715, s: 1.28, dur: '9s' },
|
||||||
{ key: 'turbine-2', x: 828, s: 0.6, dur: '13s' }
|
{ key: 'turbine-2', x: 828, s: 0.88, dur: '13s' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import { ElButton, ElTag } from 'element-plus';
|
import { ElButton, ElTag } from 'element-plus';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { Box, DeleteFilled, Document, Menu, VideoPause, VideoPlay } from '@element-plus/icons-vue';
|
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 { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||||
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
||||||
import { useDict } from '@/hooks/business/dict';
|
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
|
||||||
import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
|
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 ProductOperateDialog from './modules/product-operate-dialog.vue';
|
||||||
import ProductSearch from './modules/product-search.vue';
|
import ProductSearch from './modules/product-search.vue';
|
||||||
|
|
||||||
@@ -23,14 +20,12 @@ interface StatusVisualMeta {
|
|||||||
icon: Component;
|
icon: Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
|
|
||||||
|
|
||||||
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
||||||
|
|
||||||
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
||||||
return {
|
return {
|
||||||
pageNo: 1,
|
pageNo: 1,
|
||||||
pageSize: 20,
|
pageSize: -1,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
directionCode: undefined,
|
directionCode: undefined,
|
||||||
managerUserId: 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[]) {
|
function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
|
||||||
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
|
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,不做本地名称映射 */
|
/** 状态视觉资产(icon/tone)是前端本地映射;状态名直接渲染后端 statusName,不做本地名称映射 */
|
||||||
const STATUS_VISUALS: Record<string, StatusVisualMeta> = {
|
const STATUS_VISUALS: Record<string, StatusVisualMeta> = {
|
||||||
active: { tone: 'teal', icon: VideoPlay },
|
active: { tone: 'teal', icon: VideoPlay },
|
||||||
@@ -89,7 +58,11 @@ const operateVisible = ref(false);
|
|||||||
const editingRow = ref<Api.Product.Product | null>(null);
|
const editingRow = ref<Api.Product.Product | null>(null);
|
||||||
const { routerPush } = useRouterPush();
|
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 升序) */
|
/** 状态看板项(overview-summary items,状态机动态下发,已按 sort 升序) */
|
||||||
const statusBoardItems = ref<Api.Product.OverviewStatusItem[]>([]);
|
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]));
|
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(() => [
|
const statusItems = computed(() => [
|
||||||
{ key: 'all', label: '全部产品', count: statusBoardTotal.value, tone: 'sky' as StatusNavTone, icon: Menu },
|
{ key: 'all', label: '全部产品', count: statusBoardTotal.value, tone: 'sky' as StatusNavTone, icon: Menu },
|
||||||
...statusBoardItems.value.map(item => {
|
...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 {
|
function createRequestParams(): Api.Product.ProductSearchParams {
|
||||||
return {
|
return {
|
||||||
...searchParams,
|
...searchParams,
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: -1,
|
||||||
keyword: searchParams.keyword?.trim() || undefined,
|
keyword: searchParams.keyword?.trim() || undefined,
|
||||||
statusCode: selectedStatus.value === 'all' ? undefined : selectedStatus.value
|
statusCode: selectedStatus.value === 'all' ? undefined : selectedStatus.value
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
async function loadProducts() {
|
||||||
ProductPageResponse,
|
loading.value = true;
|
||||||
Api.Product.Product
|
|
||||||
>({
|
try {
|
||||||
paginationProps: {
|
const { error, data } = await fetchGetProductPage(createRequestParams());
|
||||||
currentPage: searchParams.pageNo,
|
allProducts.value = !error && data ? data.list : [];
|
||||||
pageSize: searchParams.pageSize
|
} catch {
|
||||||
},
|
allProducts.value = [];
|
||||||
api: () => fetchGetProductPage(createRequestParams()),
|
window.$message?.error('产品列表加载失败');
|
||||||
transform: response => transformProductPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
} finally {
|
||||||
onPaginationParamsChange: params => {
|
loading.value = false;
|
||||||
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 loadManagerOptions() {
|
async function loadManagerOptions() {
|
||||||
const { error, data: userList } = await fetchGetUserSimpleList();
|
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||||
@@ -223,31 +137,23 @@ async function loadOverviewData() {
|
|||||||
statusBoardTotal.value = error || !overviewSummary ? 0 : overviewSummary.total;
|
statusBoardTotal.value = error || !overviewSummary ? 0 : overviewSummary.total;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
|
async function refreshPageData() {
|
||||||
await getDataByPage(page);
|
await Promise.all([loadManagerOptions(), loadOverviewData(), loadProducts()]);
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPageData(page = searchParams.pageNo ?? 1) {
|
|
||||||
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProductTable(page)]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSearch() {
|
async function handleSearch() {
|
||||||
await reloadProductTable(1);
|
await loadProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResetSearch() {
|
async function handleResetSearch() {
|
||||||
const pageSize = searchParams.pageSize ?? 10;
|
Object.assign(searchParams, getInitSearchParams());
|
||||||
|
await loadProducts();
|
||||||
Object.assign(searchParams, getInitSearchParams(), {
|
|
||||||
pageSize
|
|
||||||
});
|
|
||||||
|
|
||||||
await reloadProductTable(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatusChange(status: string) {
|
async function handleStatusChange(status: string) {
|
||||||
selectedStatus.value = status;
|
selectedStatus.value = status;
|
||||||
await Promise.all([loadOverviewData(), reloadProductTable(1)]);
|
allCollapsed.value = false;
|
||||||
|
await Promise.all([loadOverviewData(), loadProducts()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -267,7 +173,7 @@ async function enterProductContext(row: Api.Product.Product) {
|
|||||||
async function handleProductSubmitted(productId?: string) {
|
async function handleProductSubmitted(productId?: string) {
|
||||||
const isEditing = Boolean(productId && editingRow.value?.id === productId);
|
const isEditing = Boolean(productId && editingRow.value?.id === productId);
|
||||||
|
|
||||||
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
|
await refreshPageData();
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
editingRow.value = null;
|
editingRow.value = null;
|
||||||
@@ -319,7 +225,7 @@ onMounted(async () => {
|
|||||||
@search="handleSearch"
|
@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>
|
<template #header>
|
||||||
<div class="product-card-header">
|
<div class="product-card-header">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@@ -331,43 +237,45 @@ onMounted(async () => {
|
|||||||
getProductStatusLabel(selectedStatus)
|
getProductStatusLabel(selectedStatus)
|
||||||
}}
|
}}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
<ElTag effect="plain">{{ allProducts.length }}</ElTag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TableHeaderOperation
|
<div class="flex flex-none items-center gap-8px">
|
||||||
v-model:columns="columnChecks"
|
<ElButton
|
||||||
:disabled-delete="true"
|
plain
|
||||||
:loading="loading"
|
:disabled="!showDirectionLayer || !allProducts.length"
|
||||||
@refresh="refreshPageData"
|
@click="allCollapsed = !allCollapsed"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #icon>
|
||||||
<ElButton plain type="primary" @click="openCreate">
|
<icon-ic-round-unfold-more v-if="allCollapsed" class="text-icon" />
|
||||||
<template #icon>
|
<icon-ic-round-unfold-less v-else class="text-icon" />
|
||||||
<icon-ic-round-plus class="text-icon" />
|
</template>
|
||||||
</template>
|
{{ allCollapsed ? '展开全部' : '折叠全部' }}
|
||||||
新增
|
</ElButton>
|
||||||
</ElButton>
|
<ElButton plain :loading="loading" @click="refreshPageData">
|
||||||
</template>
|
<template #icon>
|
||||||
</TableHeaderOperation>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
<ProductGroupedTable
|
||||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
:products="allProducts"
|
||||||
|
:loading="loading"
|
||||||
<template #empty>
|
:show-direction-layer="showDirectionLayer"
|
||||||
<ElEmpty description="当前筛选条件下暂无产品" />
|
:all-collapsed="allCollapsed"
|
||||||
</template>
|
:manager-label-map="managerLabelMap"
|
||||||
</ElTable>
|
@enter="enterProductContext"
|
||||||
</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>
|
</div>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
@@ -483,12 +391,6 @@ onMounted(async () => {
|
|||||||
color: rgb(225 29 72 / 92%);
|
color: rgb(225 29 72 / 92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.product-table-card-body) {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-card-header {
|
.product-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -496,10 +398,6 @@ onMounted(async () => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-name-link {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 1280px) {
|
@media (width <= 1280px) {
|
||||||
.product-card-header,
|
.product-card-header,
|
||||||
.product-status-item__top {
|
.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 { computed, ref, watch } from 'vue';
|
||||||
import { ElButton, ElEmpty, ElProgress, ElTag, ElTooltip } from 'element-plus';
|
import { ElButton, ElEmpty, ElProgress, ElTag, ElTooltip } from 'element-plus';
|
||||||
import dayjs from 'dayjs';
|
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 { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
import { useDict } from '@/hooks/business/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';
|
import { getProjectStatusLabel, getProjectStatusTagType } from '../../shared/project-master-data';
|
||||||
|
|
||||||
defineOptions({ name: 'ProjectGroupedTable' });
|
defineOptions({ name: 'ProjectGroupedTable' });
|
||||||
@@ -202,7 +203,8 @@ interface FlatRow {
|
|||||||
project?: Api.Project.Project;
|
project?: Api.Project.Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMN_COUNT = 7;
|
// 项目行列数(名称/类型/经理/我的角色/进度/计划周期/状态/更新),非项目行整行合并用
|
||||||
|
const COLUMN_COUNT = 8;
|
||||||
|
|
||||||
/** 单个产品组铺平为行:产品行 +(未折叠时)占位/项目/收纳行 */
|
/** 单个产品组铺平为行:产品行 +(未折叠时)占位/项目/收纳行 */
|
||||||
function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
|
function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
|
||||||
@@ -218,7 +220,12 @@ function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const project of visibleProjects(group)) {
|
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) {
|
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">
|
<div v-else-if="row.rowType === 'product'" class="pg-prod-line">
|
||||||
<ElIcon class="pg-toggle"><ArrowDown /></ElIcon>
|
<ElIcon class="pg-toggle"><ArrowDown /></ElIcon>
|
||||||
<span class="pg-prod-icon" :class="{ 'pg-prod-icon--orphan': row.group.orphan }">
|
<span class="pg-prod-icon" :class="{ 'pg-prod-icon--orphan': row.group.orphan }">
|
||||||
<ElIcon>
|
<ElIcon v-if="row.group.orphan"><QuestionFilled /></ElIcon>
|
||||||
<QuestionFilled v-if="row.group.orphan" />
|
<icon-material-symbols-package-2 v-else />
|
||||||
<Collection v-else />
|
|
||||||
</ElIcon>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="pg-prod-name" :class="{ 'pg-prod-name--orphan': row.group.orphan }">
|
<span class="pg-prod-name" :class="{ 'pg-prod-name--orphan': row.group.orphan }">
|
||||||
{{ row.group.productName }}
|
{{ row.group.productName }}
|
||||||
@@ -429,10 +434,15 @@ function overdueDays(project: Api.Project.Project) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="row.rowType === 'project'" class="pg-proj-name">
|
<div v-else-if="row.rowType === 'project'" class="pg-proj-name">
|
||||||
<ElButton link type="primary" class="pg-proj-link" @click="emit('enter', row.project)">
|
<span class="pg-proj-icon">
|
||||||
{{ row.project.projectName }}
|
<icon-ic-round-folder />
|
||||||
</ElButton>
|
</span>
|
||||||
<div class="pg-sub-code">{{ row.project.projectCode }}</div>
|
<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>
|
||||||
|
|
||||||
<div v-else class="pg-more-line">
|
<div v-else class="pg-more-line">
|
||||||
@@ -454,18 +464,24 @@ function overdueDays(project: Api.Project.Project) {
|
|||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
|
||||||
<ElTableColumn label="项目类型" width="110" align="left" show-overflow-tooltip>
|
<ElTableColumn label="项目类型" width="110" align="center" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="row.rowType === 'project'">{{ getTypeLabel(row.project.projectType, '--') }}</template>
|
<template v-if="row.rowType === 'project'">{{ getTypeLabel(row.project.projectType, '--') }}</template>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
|
||||||
<ElTableColumn label="项目经理" width="100" align="left" show-overflow-tooltip>
|
<ElTableColumn label="项目经理" width="100" align="center" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="row.rowType === 'project'">{{ projectManagerLabel(row.project) }}</template>
|
<template v-if="row.rowType === 'project'">{{ projectManagerLabel(row.project) }}</template>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</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">
|
<ElTableColumn label="进度" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.rowType === 'project'" class="pg-progress">
|
<div v-if="row.rowType === 'project'" class="pg-progress">
|
||||||
@@ -660,13 +676,44 @@ function overdueDays(project: Api.Project.Project) {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 提示行 / 项目行 / 收纳行:统一缩进到产品名起始位 ===
|
// === 提示行 / 收纳行:统一缩进到产品名起始位 ===
|
||||||
.pg-hint,
|
.pg-hint,
|
||||||
.pg-more-line,
|
.pg-more-line {
|
||||||
.pg-proj-name {
|
|
||||||
margin-left: 55px;
|
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 {
|
.pg-hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface ViewContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTaskViewContext() {
|
export function useTaskViewContext() {
|
||||||
const state = reactive<ViewContext>({ type: 'my' });
|
const state = reactive<ViewContext>({ type: 'all' });
|
||||||
|
|
||||||
function switchToMine() {
|
function switchToMine() {
|
||||||
state.type = 'my';
|
state.type = 'my';
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const { context: viewContext, switchToMine, switchToAll } = useTaskViewContext()
|
|||||||
|
|
||||||
// 执行域独立视角:跟任务域 viewContext 完全独立,切换互不影响。
|
// 执行域独立视角:跟任务域 viewContext 完全独立,切换互不影响。
|
||||||
// 用 inline reactive 即可,不抽 composable(只在本页用,YAGNI)。
|
// 用 inline reactive 即可,不抽 composable(只在本页用,YAGNI)。
|
||||||
const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'my' });
|
const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'all' });
|
||||||
|
|
||||||
function switchExecutionToMine() {
|
function switchExecutionToMine() {
|
||||||
executionViewContext.type = 'my';
|
executionViewContext.type = 'my';
|
||||||
@@ -406,8 +406,8 @@ async function handleExecutionStatusFilter(status: ExecutionStatusFilter) {
|
|||||||
selectedStatus.value = status;
|
selectedStatus.value = status;
|
||||||
// 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution
|
// 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution
|
||||||
if (selectedExecution.value) selectedExecution.value = null;
|
if (selectedExecution.value) selectedExecution.value = null;
|
||||||
// 左侧范围切换默认回「我参与的」视角,符合"在该范围内看自己的"主用法
|
// 左侧范围切换默认回「所有任务」视角,符合"在该范围内看全部"的主用法
|
||||||
switchToMine();
|
switchToAll();
|
||||||
await reloadExecutionData(1);
|
await reloadExecutionData(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,9 +437,9 @@ async function getExecutionDetail(row: Api.Project.ProjectExecution) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectExecution(row: Api.Project.ProjectExecution) {
|
function handleSelectExecution(row: Api.Project.ProjectExecution) {
|
||||||
// 锚定具体执行,默认回「我参与的」视角
|
// 锚定具体执行,默认回「所有任务」视角
|
||||||
selectedExecution.value = row;
|
selectedExecution.value = row;
|
||||||
switchToMine();
|
switchToAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectPerspective(type: 'my' | 'all') {
|
function handleSelectPerspective(type: 'my' | 'all') {
|
||||||
|
|||||||
@@ -377,11 +377,11 @@ async function loadFormOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!companyResult.error) {
|
if (!companyResult.error) {
|
||||||
companyOptions.value = companyResult.data;
|
companyOptions.value = companyResult.data.list;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgCodeResult.error) {
|
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" />
|
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
|
||||||
<span>每日工时</span>
|
<span>每日工时</span>
|
||||||
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
|
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
|
||||||
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
|
<span class="ww-section-info-wrap">
|
||||||
|
<SvgIcon icon="mdi:information-outline" class="ww-section-info" />
|
||||||
|
</span>
|
||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,11 +537,16 @@ watch(activeTab, async tab => {
|
|||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.ww-section-info-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.ww-section-info {
|
.ww-section-info {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: help;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ww-pie-wrap {
|
.ww-pie-wrap {
|
||||||
|
|||||||
Reference in New Issue
Block a user